diff --git a/.github/workflows/dev-ci-cd.yml b/.github/workflows/dev-ci-cd.yml index af726fe..c30caa8 100644 --- a/.github/workflows/dev-ci-cd.yml +++ b/.github/workflows/dev-ci-cd.yml @@ -9,7 +9,7 @@ name: 키키하이 dev CI-CD 파이프라인 on: push: - branches: [ "develop"] + branches: [ "develop","feat/product/KIKI-61-ElasticSearch"] jobs: #1. 개발 서버 CI, Build 용 diff --git a/docker/dev-docker-compose.yml b/docker/dev-docker-compose.yml index 9a8d538..185da60 100644 --- a/docker/dev-docker-compose.yml +++ b/docker/dev-docker-compose.yml @@ -12,11 +12,13 @@ services: - '3306:3306' volumes: - ./mysql/data:/var/lib/mysql + - ./mysql/my.cnf:/etc/my.cnf environment: - MYSQL_DATABASE=${MYSQL_DATABASE} - MYSQL_USER=${MYSQL_USER} - MYSQL_PASSWORD=${MYSQL_PASSWORD} - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - TZ=Asia/Seoul # Redis redis: @@ -37,7 +39,6 @@ services: - xpack.license.self_generated.type=${LICENSE} ports: - ${ES_PORT}:9200 - mem_limit: ${ES_MEM_LIMIT} ulimits: memlock: soft: -1 @@ -56,29 +57,8 @@ services: - ELASTICSEARCH_HOSTS=http://es01:9200 ports: - ${KIBANA_PORT}:5601 - mem_limit: ${KB_MEM_LIMIT} healthcheck: test: [ "CMD-SHELL", "curl -s -I http://localhost:5601 | grep -q 'HTTP/1.1 302 Found'" ] interval: 10s timeout: 10s retries: 120 - - logstash01: - image: docker.elastic.co/logstash/logstash:${STACK_VERSION} - container_name: kkh-logstash01 - user: root - volumes: - - "./logstash/logstash_ingest_data/:/usr/share/logstash/ingest_data/" - - "./logstash/pipeline/logstash.conf:/usr/share/logstash/pipeline/logstash.conf:ro" - environment: - - xpack.monitoring.enabled=false - - ELASTIC_HOSTS=http://es01:9200 - - prometheus: - image: prom/prometheus - container_name: prometheus - volumes: - - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ports: - - 9090:9090 - restart: always diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5139c8c..a10c995 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -60,11 +60,13 @@ services: - '3306:3306' volumes: - ./mysql/data:/var/lib/mysql + - ./mysql/my.cnf:/etc/my.cnf environment: - MYSQL_DATABASE=${MYSQL_DATABASE} - MYSQL_USER=${MYSQL_USER} - MYSQL_PASSWORD=${MYSQL_PASSWORD} - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - TZ=Asia/Seoul networks: - monstache-network @@ -173,7 +175,7 @@ services: - xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt - xpack.security.transport.ssl.verification_mode=certificate - xpack.license.self_generated.type=${LICENSE} - mem_limit: 1g + mem_limit: ${ES_MEM_LIMIT} networks: - monstache-network ulimits: @@ -222,7 +224,7 @@ services: - XPACK_SECURITY_ENCRYPTIONKEY=${ENCRYPTION_KEY} - XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY=${ENCRYPTION_KEY} - XPACK_REPORTING_ENCRYPTIONKEY=${ENCRYPTION_KEY} - mem_limit: 1g + mem_limit: ${KB_MEM_LIMIT} networks: - monstache-network healthcheck: diff --git a/docker/mysql/my.cnf b/docker/mysql/my.cnf new file mode 100644 index 0000000..9092b8b --- /dev/null +++ b/docker/mysql/my.cnf @@ -0,0 +1,20 @@ +[client] +default-character-set = utf8mb4 + +[mysql] +default-character-set = utf8mb4 + +[mysqld] +# Character set & collation +character-set-server = utf8mb4 +collation-server = utf8mb4_general_ci + +# Timezone +default-time-zone = 'Asia/Seoul' + +# Skip host name resolve (성능 향상) +skip-host-cache +skip-name-resolve + +# Other configs +max_connections = 200 diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/ProductDetailResponse.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/ProductDetailResponse.java index 36b1190..b2e0d64 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/ProductDetailResponse.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/ProductDetailResponse.java @@ -4,7 +4,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; -import java.util.List; +import java.util.*; + +import static site.kikihi.custom.platform.adapter.in.web.dto.response.util.ProductPriceUtil.getPrice; /** * 상품 상세 응답 DTO @@ -14,12 +16,8 @@ * @param manufacturerName 제조사명 * @param category 카테고리 * @param productName 제품명 - * @param discountRate 할인율 (예: 0.15는 15%) * @param originalPrice 최저가 - * @param discountedPrice 할인가 * @param likedByMe 나의 북마크 여부 - * @param deliveryInfo 배송 정보 (배송비, 배송 종류, 예상 도착일 등) - * @param recommendedItems 어울리는 상품 목록 * @param cautions 상품 유의사항 * @param imageUrl 상세 정보 이미지 URL 목록 */ @@ -45,27 +43,18 @@ public record ProductDetailResponse( @Schema(description = "제품명", example = "독거미 Aula F99") String productName, - @Schema(description = "할인율 (예: 0.15=15%)", example = "0.15") - double discountRate, - - @Schema(description = "정상가(원)", example = "599000.0") - double originalPrice, - - @Schema(description = "할인가(원)", example = "509000.0") - double discountedPrice, + @Schema(description = "최저가 가격부터", example = "599000 ~") + String originalPrice, @Schema(description = "북마크(좋아요)한 상품 여부", example = "true") boolean likedByMe, - @Schema(description = "배송 정보 응답 DTO", implementation = DeliveryInfoResponse.class) - DeliveryInfoResponse deliveryInfo, - - @Schema(description = "어울리는 추천 상품 목록", implementation = RecommendedItemResponse.class) - List recommendedItems, - @Schema(description = "상품 유의사항", example = "도착일은 배송지나 배송사 사정으로 변경 또는 지연될 수 있습니다.") String cautions, + @Schema(description = "상품 옵션") + List options, + @Schema(description = "상세 정보 이미지 URL 목록", example = "[\"https://example.com/img1.jpg\", \"https://example.com/img2.jpg\"]") List imageUrl ) { @@ -78,12 +67,9 @@ public static ProductDetailResponse from(Product product) { .manufacturerName(product.getManufacturer()) .category(product.getCategory()) .productName(product.getName()) - .discountRate(0) - .originalPrice(product.getPrice()) - .discountedPrice(product.getPrice()) + .originalPrice(getPrice(product)) .likedByMe(false) - .deliveryInfo(DeliveryInfoResponse.of(3000, "일반배송", "3일 이내 발송 예정")) - .recommendedItems(null) + .options(ProductOptions.from(product)) .cautions("도착일은 배송지나 배송사 사정으로 변경 또는 지연될 수 있습니다.") .imageUrl(product.getAllDetailImages()) .build(); @@ -93,19 +79,70 @@ public static ProductDetailResponse from(Product product) { public static ProductDetailResponse from(Product product, boolean likedByMe) { return ProductDetailResponse.builder() .id(product.getId()) + .thumbnail(product.getThumbnail()) .manufacturerName(product.getManufacturer()) .category(product.getCategory()) .productName(product.getName()) - .discountRate(0) - .originalPrice(product.getPrice()) - .discountedPrice(product.getPrice()) + .originalPrice(getPrice(product)) .likedByMe(likedByMe) - .deliveryInfo(DeliveryInfoResponse.of(3000, "일반배송", "3일 이내 발송 예정")) - .recommendedItems(null) + .options(ProductOptions.from(product)) .cautions("도착일은 배송지나 배송사 사정으로 변경 또는 지연될 수 있습니다.") .imageUrl(product.getAllDetailImages()) .build(); } + /// 내부에서만 사용되는 옵션 + @Builder + private record ProductOptions( + String optionName, + Double price, + String site, + String siteUrl + ) { + + /// 정적 팩토리 메서드 + private static ProductOptions of(String optionName, Double price, String site, String siteUrl) { + return ProductOptions.builder() + .optionName(optionName) + .price(price) + .site(site) + .siteUrl(siteUrl) + .build(); + } + + /// 정적 팩토리 메서드 + public static List from(Product product) { + List> productOptions = Optional.ofNullable(product.getOptions()) + .orElse(Collections.emptyList()); + + return productOptions.stream() + /// option_name 없는 건 필터링 + .filter(option -> option.get("option_name") != null) + .map(option -> { + + /// 옵션 명 가져오기 + String optionName = (String) option.get("option_name"); + Double price = null; + + /// 가격이 있다면 + if (option.get("main_price") != null) { + price = Double.valueOf(option.get("main_price").toString()); + } + + String site = null; + String siteUrl = null; + Object vendorsObj = option.get("vendors"); + if (vendorsObj instanceof Map vendors) { + site = (String) vendors.get("shop"); + siteUrl = (String) vendors.get("url"); + } + + return of(optionName, price, site, siteUrl); + }) + .toList(); + } + } + + } diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/ProductListResponse.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/ProductListResponse.java index 62701c4..549ddf0 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/ProductListResponse.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/ProductListResponse.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.Set; +import static site.kikihi.custom.platform.adapter.in.web.dto.response.util.ProductPriceUtil.getPrice; + /** * 상품 목록 응답 DTO * @@ -15,9 +17,8 @@ * @param category 카테고리 * @param manufacturerName 제조사명 * @param productName 제품명 - * @param discountRate 할인율 (예: 0.2 = 20%) - * @param discountedPrice 할인가 (정상가에서 할인 적용된 가격) * @param likedByMe 나의 좋아요 여부 (true/false) + * @param discountedPrice 가격 (기존 DTO 컬럼과 동일하게 유지) */ @Builder @@ -39,11 +40,8 @@ public record ProductListResponse( @Schema(description = "제품명", example = "맥북 키보드") String productName, - @Schema(description = "할인율 (0.2 = 20%)", example = "0.15") - double discountRate, - - @Schema(description = "할인가 (정상가에서 할인 적용된 가격)", example = "339000.0") - double discountedPrice, + @Schema(description = "최저가 가격부터", example = "599000원 ~") + String discountedPrice, @Schema(description = "내가 좋아요(북마크)한 상품 여부", example = "true") boolean likedByMe @@ -57,8 +55,7 @@ public static ProductListResponse from(Product product) { .category(product.getCategory()) .manufacturerName(product.getManufacturer()) .productName(product.getName()) - .discountRate(0) - .discountedPrice(product.getPrice()) + .discountedPrice(getPrice(product)) .likedByMe(false) .build(); } @@ -87,8 +84,7 @@ public static ProductListResponse from(Product product, boolean likedByMe) { .category(product.getCategory()) .manufacturerName(product.getManufacturer()) .productName(product.getName()) - .discountRate(0) - .discountedPrice(product.getPrice()) + .discountedPrice(getPrice(product)) .likedByMe(likedByMe) .build(); } diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/util/ProductPriceUtil.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/util/ProductPriceUtil.java new file mode 100644 index 0000000..cfa3d0d --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/util/ProductPriceUtil.java @@ -0,0 +1,38 @@ +package site.kikihi.custom.platform.adapter.in.web.dto.response.util; + +import org.springframework.stereotype.Component; +import site.kikihi.custom.platform.domain.product.Product; + +import java.util.*; + +@Component +public class ProductPriceUtil { + + /// 최저가 가격을 위한 설정 + public static String getPrice(Product product) { + + // 옵션 Optional 처리 + List> productOptions = Optional.ofNullable(product.getOptions()) + .orElse(Collections.emptyList()); + + // 옵션이 없다면 기본 가격 제공 + if (productOptions.isEmpty()) { + return (int) product.getPrice() + "원"; + } + + // 옵션 중 최저가 찾기 + OptionalDouble minPrice = productOptions.stream() + .map(option -> option.get("main_price")) + .filter(Objects::nonNull) + .mapToDouble(price -> Double.parseDouble(price.toString())) + .min(); + + // 최저가 있으면 옵션 가격으로, 없으면 기본 가격으로 + if (minPrice.isPresent()) { + return (int) minPrice.getAsDouble() + "원 ~"; + } else { + return (int) product.getPrice() + "원"; + } + } + +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/BookmarkControllerSpec.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/BookmarkControllerSpec.java index d915d37..2563a13 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/BookmarkControllerSpec.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/BookmarkControllerSpec.java @@ -88,7 +88,7 @@ ApiResponse deleteBookmark( String SUCCESS_PAYLOAD = """ { - "productId": "686bd26dff7b820865d6e005" + "productId": "6896ed675198cf586e933d6c" } """; } diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/ProductControllerSpec.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/ProductControllerSpec.java index 882dfab..cedca96 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/ProductControllerSpec.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/ProductControllerSpec.java @@ -28,7 +28,7 @@ public interface ProductControllerSpec { description = "ID를 바탕으로 상품 상세정보를 조회할 수 있습니다." ) ApiResponse getProduct( - @Parameter(description = "상품 ID", example = "686bd26dff7b820865d6e005") + @Parameter(description = "상품 ID", example = "6896ed675198cf586e933d6c") @RequestParam String id, @Parameter(hidden = true) diff --git a/src/main/java/site/kikihi/custom/platform/adapter/out/elasticSearch/ProductESDocument.java b/src/main/java/site/kikihi/custom/platform/adapter/out/elasticSearch/ProductESDocument.java index 2297df5..d87f489 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/out/elasticSearch/ProductESDocument.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/out/elasticSearch/ProductESDocument.java @@ -23,7 +23,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder -@Document(indexName = "products") +@Document(indexName = "kikihi.products") public class ProductESDocument { @Id @@ -43,7 +43,7 @@ public class ProductESDocument { private List description; // @Field(type = FieldType.Text) - private String thumbnailUrl; + private String thumbnail; @Field(type = FieldType.Keyword) private String manufacturer; // @@ -71,7 +71,7 @@ public static ProductESDocument from(Product product) { .name(product.getName()) .price(product.getPrice()) .description(product.getDescription()) - .thumbnailUrl(product.getThumbnail()) + .thumbnail(product.getThumbnail()) .manufacturer(product.getManufacturer()) .detailPageUrl(product.getDetailPageUrl()) .options(product.getOptions()) @@ -88,7 +88,7 @@ public Product toDomain() { .category(category) .price(price) .description(description) - .thumbnail(thumbnailUrl) + .thumbnail(thumbnail) .manufacturer(manufacturer) .detailPageUrl(detailPageUrl) .options(options) diff --git a/src/main/java/site/kikihi/custom/platform/adapter/out/mongo/product/ProductDocument.java b/src/main/java/site/kikihi/custom/platform/adapter/out/mongo/product/ProductDocument.java index 3b771b8..571b706 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/out/mongo/product/ProductDocument.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/out/mongo/product/ProductDocument.java @@ -6,8 +6,6 @@ import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,6 +32,7 @@ public class ProductDocument { private List description; // + @Field("thumbnail") private String thumbnail; // private String manufacturer; // @@ -42,7 +41,7 @@ public class ProductDocument { private String detailPageUrl; // @Field("options") - private List options; // + private List> options; // @Field("all_detail_images") private List allDetailImages; // @@ -58,36 +57,9 @@ public Product toDomain(){ .thumbnail(thumbnail) .manufacturer(manufacturer) .detailPageUrl(detailPageUrl) - .options(convertOptions()) + .options(options) .allDetailImages(allDetailImages) .build(); } - - /** - * MongoDB에서 가져온 옵션을 List> 형태로 안전하게 변환 - */ - private List> convertOptions() { - - /// 결과 리턴할 리스트 생성 - List> result = new ArrayList<>(); - - /// 예외처리 - if (options == null) { - return result;} - - /// 반복문 - for (Object o : options) { - if (o instanceof String) { - /// 문자열 옵션 -> Map으로 변환 - Map map = new HashMap<>(); - map.put("option_name", o); - result.add(map); - } else if (o instanceof Map) { - /// Object 옵션 그대로 변환 - result.add((Map) o); - } - } - return result; - } } diff --git a/src/test/java/site/kikihi/custom/platform/application/service/ProductServiceIntTest.java b/src/test/java/site/kikihi/custom/platform/application/service/ProductServiceIntTest.java index decdbc1..ed11086 100644 --- a/src/test/java/site/kikihi/custom/platform/application/service/ProductServiceIntTest.java +++ b/src/test/java/site/kikihi/custom/platform/application/service/ProductServiceIntTest.java @@ -113,7 +113,6 @@ void detailLoad_user_no_bookmark_happy() { assertNotNull(response); assertTrue(response.likedByMe()); Assertions.assertEquals(response.productName(), product1.getName()); - Assertions.assertEquals(response.originalPrice(), product1.getPrice()); } @Test diff --git a/src/test/java/site/kikihi/custom/platform/domain/product/ProductFixtures.java b/src/test/java/site/kikihi/custom/platform/domain/product/ProductFixtures.java index c9c6195..5d87ee1 100644 --- a/src/test/java/site/kikihi/custom/platform/domain/product/ProductFixtures.java +++ b/src/test/java/site/kikihi/custom/platform/domain/product/ProductFixtures.java @@ -2,12 +2,18 @@ import site.kikihi.custom.platform.adapter.out.mongo.product.ProductDocument; -import java.util.Arrays; -import java.util.UUID; +import java.util.*; public class ProductFixtures { + public static ProductDocument fakeProduct() { + + Map options = new HashMap<>(); + options.put("option_name", "화이트"); + options.put("option_name", "블랙"); + options.put("option_name", "실버"); + return ProductDocument.builder() .id(UUID.randomUUID().toString()) .category("test") @@ -16,7 +22,7 @@ public static ProductDocument fakeProduct() { .description(Arrays.asList("고성능", "에너지 절약", "심플 디자인")) .thumbnail("https://example.com/images/prd-001-thumb.jpg") .detailPageUrl("https://example.com/products/prd-001/detail") - .options(Arrays.asList("화이트", "블랙", "실버")) + .options(List.of(options)) .allDetailImages(Arrays.asList( "https://example.com/images/prd-001-1.jpg", "https://example.com/images/prd-001-2.jpg" @@ -26,6 +32,12 @@ public static ProductDocument fakeProduct() { } public static ProductDocument createProduct() { + + Map options = new HashMap<>(); + options.put("option_name", "화이트"); + options.put("option_name", "블랙"); + options.put("option_name", "실버"); + return ProductDocument.builder() .id(UUID.randomUUID().toString()) .category("test") @@ -34,7 +46,7 @@ public static ProductDocument createProduct() { .description(Arrays.asList("고성능", "에너지 절약", "심플 디자인")) .thumbnail("https://example.com/images/prd-001-thumb.jpg") .detailPageUrl("https://example.com/products/prd-001/detail") - .options(Arrays.asList("화이트", "블랙", "실버")) + .options(List.of(options)) .allDetailImages(Arrays.asList( "https://example.com/images/prd-001-1.jpg", "https://example.com/images/prd-001-2.jpg" @@ -44,6 +56,12 @@ public static ProductDocument createProduct() { } public static ProductDocument createProduct(String category, String name,double price) { + + Map options = new HashMap<>(); + options.put("option_name", "화이트"); + options.put("option_name", "블랙"); + options.put("option_name", "실버"); + return ProductDocument.builder() .id(UUID.randomUUID().toString()) .category(category) @@ -52,7 +70,7 @@ public static ProductDocument createProduct(String category, String name,double .description(Arrays.asList("고성능", "에너지 절약", "심플 디자인")) .thumbnail("https://example.com/images/prd-001-thumb.jpg") .detailPageUrl("https://example.com/products/prd-001/detail") - .options(Arrays.asList("화이트", "블랙", "실버")) + .options(List.of(options)) .allDetailImages(Arrays.asList( "https://example.com/images/prd-001-1.jpg", "https://example.com/images/prd-001-2.jpg" @@ -63,6 +81,11 @@ public static ProductDocument createProduct(String category, String name,double public static ProductDocument createProduct(String category, String name, String manufacturer, double price) { + Map options = new HashMap<>(); + options.put("option_name", "화이트"); + options.put("option_name", "블랙"); + options.put("option_name", "실버"); + return ProductDocument.builder() .id(UUID.randomUUID().toString()) .category(category) @@ -71,7 +94,7 @@ public static ProductDocument createProduct(String category, String name, String .description(Arrays.asList("고성능", "에너지 절약", "심플 디자인")) .thumbnail("https://example.com/images/prd-001-thumb.jpg") .detailPageUrl("https://example.com/products/prd-001/detail") - .options(Arrays.asList("화이트", "블랙", "실버")) + .options(List.of(options)) .allDetailImages(Arrays.asList( "https://example.com/images/prd-001-1.jpg", "https://example.com/images/prd-001-2.jpg"