diff --git a/build.gradle b/build.gradle index 12227a8..190f23e 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.jiyoung.kikihi' +group = 'site.kikihi.custom' version = '0.0.1-SNAPSHOT' java { diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/RecommendationController.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/RecommendationController.java index e2c0854..ee8b80b 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/in/web/RecommendationController.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/RecommendationController.java @@ -1,6 +1,10 @@ package site.kikihi.custom.platform.adapter.in.web; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; import site.kikihi.custom.global.response.ApiResponse; +import site.kikihi.custom.platform.adapter.in.web.dto.request.product.KeyboardRecommendationRequest; +import site.kikihi.custom.platform.adapter.in.web.dto.response.product.KeyboardRecommendationResponse; import site.kikihi.custom.platform.adapter.in.web.dto.response.product.ProductListResponse; import site.kikihi.custom.platform.adapter.in.web.swagger.RecommendControllerSpec; import site.kikihi.custom.platform.application.in.recommendation.RecommendationUseCase; @@ -8,9 +12,6 @@ import site.kikihi.custom.security.oauth2.domain.PrincipalDetails; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.UUID; @@ -43,6 +44,41 @@ public ApiResponse> getProductRecommendation( return ApiResponse.ok(ProductListResponse.from(recommendation)); } + /** + * 튜토리얼 키보드 추천 API + */ + @PostMapping("/tutorial") + public ApiResponse> getTutorialKeyboardRecommendation( + @AuthenticationPrincipal PrincipalDetails principalDetails, + @Valid @RequestBody KeyboardRecommendationRequest request + ) { + + // 유저가 존재하면 넣기 + UUID userId = principalDetails != null ? principalDetails.getId() : null; + + // 튜토리얼 키보드 추천 서비스 호출 + List recommendation = service.getTutorialKeyboardRecommendation(userId,request); + + // 응답 주기 + return ApiResponse.ok(recommendation); + } + /** + * 유사한 상품 추천 + */ + @GetMapping("/{productId}") + public ApiResponse> getSimilarProducts( + @PathVariable("productId") String productId, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + // 유저가 존재하면 넣기 + UUID userId = principalDetails != null ? principalDetails.getId() : null; + + // 유사한 상품 추천 서비스 호출 + List similarProducts = service.getSimilarProducts(userId,productId); + + // 응답 주기 + return ApiResponse.ok(KeyboardRecommendationResponse.from(similarProducts)); + } } diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/converter/KeyboardOptionsConverter.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/converter/KeyboardOptionsConverter.java new file mode 100644 index 0000000..49d8b00 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/converter/KeyboardOptionsConverter.java @@ -0,0 +1,69 @@ +package site.kikihi.custom.platform.adapter.in.web.converter; + +import site.kikihi.custom.platform.adapter.in.web.dto.request.product.KeyboardOptions; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class KeyboardOptionsConverter { + + // Size enum → description 검색 키워드 맵핑 + public static String mapSizeToDescription(KeyboardOptions.Size size) { + if (size == null) return null; + return switch (size) { + case TENKEYLESS -> "텐키리스"; + case FULL -> "풀배열"; + case MINI -> "미니"; + }; + } + + // SwitchType enum → options.option_name 중 매칭되는 문자열 배열 리턴 + public static List mapSwitchTypeToOptionNames(KeyboardOptions.SwitchType switchType) { + if (switchType == null) return Collections.emptyList(); + + return switch (switchType) { + case SILENT -> Arrays.asList( + "저소음 적축", "저소음 갈축", "저소음 흑축", + "저소음 바다축", "저소음 잉크축", "저소음 바닐라축", + "저소음 딸기축", "저소음 바나나축" + ); + case NORMAL -> Arrays.asList( + "갈축", "바나나축", "바닐라축", + "핑크축", "레몬축", "딸기축", "경해축", + "잉크축 V2", "모가축", "판다축", + "바다축", "라벤더축", "체리 스피드 실버", + "리니어 옵티컬" + ); + case LOUD -> Arrays.asList( + "청축", "녹축","백축","clicky" + ); + case SMOOTH -> Arrays.asList( + "적축", "흑축", "자석축", "광축", + "실버축", "스피드 적축", "잉크축", + "바다축", "바닐라축", "체리 리니어 옵티컬", + "라떼축", "모카축", "사파이어축","밀키축", + "무지개축", "크림축" + ); + }; + + } + + // Layout enum → description 검색 키워드 맵핑 + public static String mapLayoutToDescription(KeyboardOptions.Layout layout) { + if (layout == null) return null; + return switch (layout) { + case ERGONOMIC -> "스텝스컬쳐2"; + case SIMPLE -> "로우프로파일(LP)"; + }; + } + + // 키압에서 g빼기 + public static Integer mapKeyPressureToSpecTable(KeyboardOptions.KeyPressure keyPressure) { + if (keyPressure == null) return null; + return switch (keyPressure) { + case LIGHT -> 49; + case NORMAL -> 50; + }; + } +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/request/product/KeyboardOptions.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/request/product/KeyboardOptions.java new file mode 100644 index 0000000..41e85fc --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/request/product/KeyboardOptions.java @@ -0,0 +1,135 @@ +package site.kikihi.custom.platform.adapter.in.web.dto.request.product; + +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema( + name = "[요청][상품] 키보드 옵션 Enum", + description = "키보드 추천 서비스에서 사용하는 키보드 옵션 Enum입니다." +) +public class KeyboardOptions { + + @Getter + @RequiredArgsConstructor + @Schema(name = "[요청][상품] 배열(Size) Enum", description = "키보드 배열 옵션") + public enum Size { + @Schema(description = "텐키리스 배열", example = "tenkeyless") + TENKEYLESS("tenkeyless"), + + @Schema(description = "풀배열", example = "full") + FULL("full"), + + @Schema(description = "미니 배열", example = "mini") + MINI("mini"); + + private final String value; + + @JsonValue + public String getValue() { + return value; + } + } + + @Getter + @RequiredArgsConstructor + @Schema(name = "[요청][상품] 키압(Key Pressure) Enum", description = "키압 옵션") + public enum KeyPressure { + @Schema(description = "가벼운 키압 (50g 미만)", example = "light") + LIGHT("light"), + + @Schema(description = "보통 키압 (50g 이상)", example = "normal") + NORMAL("normal"); + + private final String value; + + @JsonValue + public String getValue() { + return value; + } + } + + @Getter + @RequiredArgsConstructor + @Schema(name = "[요청][상품] 키압(Key Pressure) Enum", description = "키압 옵션") + public enum Layout { + @Schema(description = "인체공학적 (스텝스컬쳐2)", example = "egonomic") + ERGONOMIC("egonomic"), + + @Schema(description = "심플하고 깔끔한 (low 프로파일)", example = "simple") + SIMPLE("simple"); + + private final String value; + + @JsonValue + public String getValue() { + return value; + } + } + + @Getter + @RequiredArgsConstructor + @Schema(name = "[요청][상품] 스위치 종류(Switch Type) Enum", description = "스위치 종류 옵션") + public enum SwitchType { + + @Schema(description = "조용한", example = "silent") + SILENT("silent"), + + @Schema(description = "적당한", example = "normal") + NORMAL("normal"), + + @Schema(description = "강한", example = "loud") + LOUD("loud"), + + @Schema(description = "부드러운", example = "smooth") + SMOOTH("smooth"); + + private final String value; + + @JsonValue + public String getValue() { + return value; + } + } + + @Getter + @RequiredArgsConstructor + @Schema(name = "[요청][상품] 흡음재 여부 Enum", description = "흡음재 유무 옵션") + public enum SoundDampener { + @Schema(description = "흡음재 있음", example = "○") + YES("○"), + + @Schema(description = "흡음재 없음", example = "X") + NO("X"); + + private final String value; + + @JsonValue + public String getValue() { + return value; + } + } + + @Getter + @RequiredArgsConstructor + @Schema(name = "[요청][상품] RGB 여부 Enum", description = "RGB 유무 옵션") + public enum RGB { + @Schema(description = "RGB 있음", example = "○") + YES("○"), + + @Schema(description = "RGB 없음", example = "X") + NO("X"); + + private final String value; + + @JsonValue + public String getValue() { + return value; + } + } + + +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/request/product/KeyboardRecommendationRequest.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/request/product/KeyboardRecommendationRequest.java new file mode 100644 index 0000000..792175c --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/request/product/KeyboardRecommendationRequest.java @@ -0,0 +1,35 @@ +package site.kikihi.custom.platform.adapter.in.web.dto.request.product; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(name = "[요청][상품] 키보드 추천 요청 DTO", description = "키보드 추천 요청에 사용하는 DTO입니다.") +public class KeyboardRecommendationRequest { + + @Schema(description = "키보드 배열(Size) 옵션", example = "ten", required = true) + private KeyboardOptions.Size size; + + @Schema(description = "키압(Key Pressure) 옵션", example = "light", required = true) + private KeyboardOptions.KeyPressure keyPressure; + + @Schema(description = "레이아웃 종류(Layout) 옵션", example = "egonomic", required = true) + private KeyboardOptions.Layout layout; + + @Schema(description = "스위치 종류(Switch Type) 옵션", example = "silent", required = true) + private KeyboardOptions.SwitchType switchType; + + @Schema(description = "흡음재(Sound Dampener) 적용 여부", example = "○", required = true) + private KeyboardOptions.SoundDampener soundDampener; + + @Schema(description = "RGB 적용 여부", example = "○", required = true) + private KeyboardOptions.RGB rgb; + + @Schema(description = "최소 가격 (단위: 원)", example = "0", required = true) + private int minPrice; + + @Schema(description = "최대 가격 (단위: 원)", example = "200000", required = true) + private int maxPrice; +} diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/KeyboardRecommendationResponse.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/KeyboardRecommendationResponse.java new file mode 100644 index 0000000..84888b3 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/dto/response/product/KeyboardRecommendationResponse.java @@ -0,0 +1,68 @@ +package site.kikihi.custom.platform.adapter.in.web.dto.response.product; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import site.kikihi.custom.platform.domain.product.Product; + +import java.util.List; + +/** + * 상품 상세 응답 DTO + * + * @param id 상품 ID + * @param thumbnail 상품 썸네일 + * @param manufacturerName 제조사명 + * @param productName 제품명 + * @param price + * @param likedByMe 나의 북마크 여부 + */ + +@Builder +@Schema(name = "KeyboardRecommendationListResponse", description = "튜토리얼 키보드 추천 리스트 응답") +public record KeyboardRecommendationResponse( + + @Schema(description = "상품 아이디", example = "101") + String id, + + @Schema(description = "상품 썸네일 이미지 URL", example = "https://example.com/product/101.jpg") + String thumbnail, + + @Schema(description = "제조사명", example = "독거미") + String manufacturerName, + + @Schema(description = "제품명", example = "독거미 Aula F99") + String productName, + + @Schema(description = "정상가(원)", example = "599000.0") + double price, + + @Schema(description = "북마크(좋아요)한 상품 여부", example = "true") + boolean likedByMe + +) { + + /// 정적 팩토리 메서드 + // 단일 객체 변환 메서드 추가 + public static KeyboardRecommendationResponse from(Product product) { + return KeyboardRecommendationResponse.builder() + .id(product.getId()) + .thumbnail(product.getThumbnail()) + .manufacturerName(product.getManufacturer()) + .productName(product.getName()) + .price(product.getPrice()) + .likedByMe(false) + .build(); + } + + + // 기존 리스트 변환 메서드는 그대로 유지 + public static List from(List productList) { + return productList.stream() + .map(KeyboardRecommendationResponse::from) // 단일 객체 변환 메서드 호출 + .toList(); + } + + + +} + 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 cedca96..c083e33 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 = "6896ed675198cf586e933d6c") + @Parameter(description = "상품 ID", example = "6896ed7d5198cf586e933d6e") @RequestParam String id, @Parameter(hidden = true) @@ -50,7 +50,7 @@ ApiResponse getProduct( ApiResponse> getProductList( PageRequest pageRequest, - @Parameter(description = "카테고리", required = true, example = "keycap") + @Parameter(description = "카테고리", required = true, example = "keyboard") @RequestParam CategoryType category, @Parameter( diff --git a/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/RecommendControllerSpec.java b/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/RecommendControllerSpec.java index fcbf180..0e9d41f 100644 --- a/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/RecommendControllerSpec.java +++ b/src/main/java/site/kikihi/custom/platform/adapter/in/web/swagger/RecommendControllerSpec.java @@ -1,6 +1,11 @@ package site.kikihi.custom.platform.adapter.in.web.swagger; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import site.kikihi.custom.global.response.ApiResponse; +import site.kikihi.custom.platform.adapter.in.web.dto.request.product.KeyboardRecommendationRequest; +import site.kikihi.custom.platform.adapter.in.web.dto.response.product.KeyboardRecommendationResponse; import site.kikihi.custom.platform.adapter.in.web.dto.response.product.ProductListResponse; import site.kikihi.custom.security.oauth2.domain.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; @@ -24,5 +29,29 @@ ApiResponse> getProductRecommendation( @AuthenticationPrincipal PrincipalDetails principalDetails ); + /** + * 튜토리얼 키보드 추천 API + */ + @Operation( + summary = "추천 키보드 리스트", + description = "튜토리얼에서 키보드 추천 리스트를 불러오는 API 입니다." + ) + ApiResponse> getTutorialKeyboardRecommendation( + @AuthenticationPrincipal PrincipalDetails principalDetails, + @Valid @RequestBody KeyboardRecommendationRequest request + ); + + /** + * 유사 상품 추천 API + */ + @Operation( + summary = "유사 상품 추천 리스트", + description = "상품 상세 페이지에서 유사한 상품 리스트를 불러오는 API 입니다." + ) + ApiResponse> getSimilarProducts( + @PathVariable("productId") String productId, + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + } diff --git a/src/main/java/site/kikihi/custom/platform/adapter/out/RecommendKeyboardAdapter.java b/src/main/java/site/kikihi/custom/platform/adapter/out/RecommendKeyboardAdapter.java new file mode 100644 index 0000000..564ccf9 --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/adapter/out/RecommendKeyboardAdapter.java @@ -0,0 +1,466 @@ +package site.kikihi.custom.platform.adapter.out; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Component; +import site.kikihi.custom.platform.adapter.in.web.dto.request.product.KeyboardOptions; +import site.kikihi.custom.platform.adapter.out.mongo.product.ProductDocument; +import site.kikihi.custom.platform.adapter.out.mongo.product.ProductDocumentRepository; +import site.kikihi.custom.platform.application.out.bookmark.BookmarkPort; +import site.kikihi.custom.platform.application.out.recommend.RecommendKeyboardPort; +import site.kikihi.custom.platform.domain.bookmark.Bookmark; +import site.kikihi.custom.platform.domain.product.Product; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecommendKeyboardAdapter implements RecommendKeyboardPort { + + private final MongoTemplate mongoTemplate; + private final BookmarkPort bookmarkPort; + + + // 키보드 추천 로직 + @Override + public List filterAndRecommendKeyboards( + UUID userId, + String size, + Integer keyPressure, + String layout, + List switchType, + String soundDampener, + String rgb, + int minPrice, + int maxPrice + ) { + Query query = new Query(); + List andCriterias = new ArrayList<>(); + + // 사이즈 + if (size != null && !size.isBlank()) { + andCriterias.add(Criteria.where("description").regex(size, "i")); + } + + // 키압 조건 + if (keyPressure != null) { + String keyPressureField = "spec_table.기능 > 키압"; + if (keyPressure <= 49) { // LIGHT 구간 + query.addCriteria(Criteria.where(keyPressureField).regex("^([0-4]?[0-9])g$")); + } else { // NORMAL 구간 + query.addCriteria(Criteria.where(keyPressureField).regex("^([5-9][0-9]|[1-9][0-9]{2,})g$")); + } + + } + // Layout + if (layout != null && !layout.isBlank()) { + if ("ERGONOMIC".equalsIgnoreCase(layout)) { + query.addCriteria(Criteria.where("spec_table.키보드구조 > 스텝스컬쳐2").is("○")); + } else if ("SIMPLE".equalsIgnoreCase(layout)) { + andCriterias.add(Criteria.where("spec_table.키보드구조 > 로우프로파일(LP)").is("○")); + andCriterias.add(Criteria.where("description").regex("스텝스컬쳐2", "i")); + andCriterias.add(Criteria.where("description").regex("lp", "i")); + } + } + + // Switch Type + if (switchType != null && !switchType.isEmpty()) { + List switchCriteria = new ArrayList<>(); + for (String sw : switchType) { + switchCriteria.add(Criteria.where("options.option_name").regex(sw, "i")); + switchCriteria.add(Criteria.where("spec_table.기능 > 키 스위치").regex(sw, "i")); + switchCriteria.add(Criteria.where("name").regex(sw, "i")); + } + andCriterias.add(new Criteria().orOperator(switchCriteria.toArray(new Criteria[0]))); + } + + // SoundDampener + if (soundDampener != null) { + if (soundDampener == KeyboardOptions.SoundDampener.YES.getValue()) { + andCriterias.add(new Criteria().orOperator( + Criteria.where("spec_table.키보드구조 > 흡음재").is("○"), + Criteria.where("description").regex("흡음재") + )); + } else { + query.addCriteria(new Criteria().andOperator( + Criteria.where("spec_table.키보드구조 > 흡음재").ne("○"), + Criteria.where("description").not().regex("흡음재") + )); + } + } + + // RGB + if (rgb != null) { + if (rgb == KeyboardOptions.RGB.YES.getValue()) { + andCriterias.add(new Criteria().orOperator( + Criteria.where("spec_table.키보드구조 > RGB 백라이트").is("○"), + Criteria.where("description").regex("RGB", "i") + )); + } else { + query.addCriteria(new Criteria().andOperator( + Criteria.where("spec_table.키보드구조 > RGB 백라이트").ne("○"), + Criteria.where("description").not().regex("RGB", "i") + )); + } + } + + // 가격 + if (minPrice > 0 || maxPrice > 0) { + query.addCriteria(Criteria.where("options").elemMatch( + Criteria.where("main_price").gte(minPrice).lte(maxPrice) + )); + } + + // And 조건 최종 적용 + if (!andCriterias.isEmpty()) { + query.addCriteria(new Criteria().andOperator(andCriterias.toArray(new Criteria[0]))); + } + + List results = mongoTemplate.find(query, ProductDocument.class); + return results.stream().map(ProductDocument::toDomain).toList(); + + } + + + // 유사 상품 추천 + @Override + public List getSimilarProducts(UUID userId, String productId, Product baseProduct) { + + // 1단계: 후보군 넓게 추출 (카테고리 동일, 가격 ±25%) + Query query = new Query(); + query.addCriteria(Criteria.where("category").is(baseProduct.getCategory())); + query.addCriteria(Criteria.where("_id").ne(baseProduct.getId())); + + double basePrice = baseProduct.getPrice(); + query.addCriteria(Criteria.where("options").elemMatch( + Criteria.where("main_price").gte((int) (basePrice * 0.75)).lte((int) (basePrice * 1.25)) + )); + + List candidates = mongoTemplate.find(query, ProductDocument.class); + log.info("후보군 추출: {}개", candidates.size()); + if (candidates.isEmpty()) return Collections.emptyList(); + + // 2단계: 유저 선호 브랜드 및 스위치 추출 + List bookmarkList = bookmarkPort.getBookmarksByUserIdAndCategoryId(userId, "keyboard"); + if (bookmarkList == null) bookmarkList = Collections.emptyList(); + + Set bookmarkedProductIds = bookmarkList.stream() + .map(Bookmark::getProductId) + .collect(Collectors.toSet()); + + Query bookmarkProductsQuery = new Query(Criteria.where("_id").in(bookmarkedProductIds)); + List bookmarkedProducts = mongoTemplate.find(bookmarkProductsQuery, ProductDocument.class); + + Set likedBrands = new HashSet<>(); + Set likedSwitches = new HashSet<>(); + for (ProductDocument doc : bookmarkedProducts) { + Product p = doc.toDomain(); + likedBrands.add(p.getManufacturer()); + + Map specTable = p.getSpecTable(); + if (specTable == null) specTable = Collections.emptyMap(); + + Object rawValue = specTable.get(normalizeKey("기능 > 키 스위치")); + String keySwitch = (rawValue instanceof String) ? normalizeSwitch((String) rawValue) : null; + if (keySwitch != null) likedSwitches.add(keySwitch); + } + log.info("유저 선호 브랜드={}, 선호 스위치={}", likedBrands, likedSwitches); + + // 3단계: 후보군 점수 계산 및 정렬 + List scoredList = new ArrayList<>(); + for (ProductDocument candidateDoc : candidates) { + Product candidate = candidateDoc.toDomain(); + SimilarityResult result = calculateSimilarityScore(baseProduct, candidate, likedBrands, likedSwitches); + if (result.getScore() > 0) { + scoredList.add(new ScoredProduct(candidate, result.getScore(), result.getReasons())); + } + + } + scoredList.sort(Comparator.comparingDouble(ScoredProduct::getScore).reversed()); + + List recommended = scoredList.stream() + .limit(6) + .map(ScoredProduct::getProduct) + .collect(Collectors.toList()); + + + // 2. 동일 제조사 상품 추가 (중복 피함) + Set pickedIds = recommended.stream().map(Product::getId).collect(Collectors.toSet()); + for (ProductDocument candidateDoc : candidates) { + Product candidate = candidateDoc.toDomain(); + if (pickedIds.contains(candidate.getId())) continue; + + boolean sameManufacturer = false; + if (candidate.getManufacturer() != null && baseProduct.getManufacturer() != null) { + sameManufacturer = candidate.getManufacturer().equals(baseProduct.getManufacturer()); + } + + if (sameManufacturer) { + recommended.add(candidate); + pickedIds.add(candidate.getId()); + if (recommended.size() > 6) break; + } + } + + // 3. 카테고리 내 랜덤 상품으로 채우기 + if (recommended.size() < 10) { + List notPickedList = candidates.stream() + .map(ProductDocument::toDomain) + .filter(p -> !pickedIds.contains(p.getId())) + .collect(Collectors.toList()); + Collections.shuffle(notPickedList); + for (Product p : notPickedList) { + recommended.add(p); + if (recommended.size() > 6) break; + } + } + + // 🔹 10개 추천 상품 상세 로그 + log.info("===== 최종 추천 6개 상품 상세 ====="); + for (int i = 0; i < Math.min(6, scoredList.size()); i++) { + ScoredProduct sp = scoredList.get(i); + Product p = sp.getProduct(); + double score = sp.getScore(); + log.info("순위 {}: 상품ID={}, 점수={}", i + 1, p.getId(), score); + } + log.info("===== 추천 리스트 종료 ====="); + return recommended; + } + + + // 유사도 점수 계산 (모듈화 버전) + private SimilarityResult calculateSimilarityScore( + Product base, Product candidate, + Set likedBrands, Set likedSwitches + ) { + double score = 0.0; + List reasons = new ArrayList<>(); + + Map baseSpec = Optional.ofNullable(base.getSpecTable()) + .orElse(Collections.emptyMap()); + Map candSpec = Optional.ofNullable(candidate.getSpecTable()) + .orElse(Collections.emptyMap()); + List> baseOptions = Optional.ofNullable(base.getOptions()) + .orElse(Collections.emptyList()); + List> candOptions = Optional.ofNullable(candidate.getOptions()) + .orElse(Collections.emptyList()); + List baseDescriptions = Optional.ofNullable(base.getDescription()) + .orElse(Collections.emptyList()); + List candDescriptions = Optional.ofNullable(candidate.getDescription()) + .orElse(Collections.emptyList()); + + // 1) 사이즈 평가 + score += evaluateSize(baseDescriptions, candDescriptions, reasons); + + // 2) 키압 평가 + score += evaluatePressure(baseSpec, candSpec, reasons); + + // 3) 스위치 평가 + score += evaluateSwitches(baseOptions, candOptions, reasons); + + // 4) 옵션 공통 항목 평가 + score += evaluateOptionOverlap(baseOptions, candOptions, reasons); + + double finalScore = Math.min(score, 1.0); + return new SimilarityResult(finalScore, reasons); + } + +// ----------------- 개별 모듈 메서드 ----------------- + + // (1) 사이즈 평가 + private double evaluateSize(List baseDescriptions, List candDescriptions, List reasons) { + List sizeKeywords = Arrays.asList("풀배열", "미니", "텐키리스"); + + String baseSize = findSizeKeyword(baseDescriptions, sizeKeywords); + String candSize = findSizeKeyword(candDescriptions, sizeKeywords); + + if (baseSize == null || candSize == null || !baseSize.equalsIgnoreCase(candSize)) { + log.info("사이즈 불일치: base={}, candidate={}", baseSize, candSize); + reasons.add("사이즈 불일치"); + return 0.0; // 불일치는 점수 없음 + } else { + log.info("사이즈 일치: {}", baseSize); + reasons.add("사이즈 일치"); + return 0.0; // 필터 조건, 점수는 부여하지 않음 + } + } + + // 사이즈 찾기 + private String findSizeKeyword(List descriptions, List keywords) { + for (String size : keywords) { + for (String desc : descriptions) { + if (desc.trim().equalsIgnoreCase(size)) { + return size; + } + } + } + return null; + } + + // (2) 키압 평가 + private double evaluatePressure(Map baseSpec, Map candSpec, List reasons) { + Integer basePressure = parsePressure(baseSpec.get("기능 > 키압")); + Integer candPressure = parsePressure(candSpec.get("기능 > 키압")); + + if (basePressure == null || candPressure == null) { + reasons.add("키압 정보 부족 - 점수 산정 제외"); + return 0.0; + } + + double diff = Math.abs(basePressure - candPressure); + if (diff > 10) { + reasons.add("키압 차이 " + diff + "g (허용 10g)"); + return 0.0; + } else { + double similarity = 1.0 - diff / 10.0; // diff=0 → similarity=1 + double s = similarity * 0.2; + reasons.add("키압 차이=" + diff + "g, 점수=+" + String.format("%.2f", s)); + return s; + } + } + + // (3) 스위치 평가 + private double evaluateSwitches(List> baseOptions, + List> candOptions, + List reasons) { + Set baseSwitches = extractSwitches(baseOptions); + List candSwitches = new ArrayList<>(extractSwitches(candOptions)); + + if (baseSwitches.isEmpty() || candSwitches.isEmpty()) { + reasons.add("스위치 정보 부족 - 점수 산정 제외"); + return 0.0; + } + + for (String cSwitch : candSwitches) { + if (baseSwitches.contains(cSwitch)) { + reasons.add("스위치 동일 (" + cSwitch + ") (+0.45)"); + return 0.45; + } + for (String bSwitch : baseSwitches) { + if (sameSwitchFamily(bSwitch, cSwitch)) { + reasons.add("스위치 유사 (" + cSwitch + ") (+0.25)"); + return 0.25; + } + } + } + reasons.add("스위치 불일치 - 점수 감소"); + return 0.0; + } + + + // 스위치 추출 + private Set extractSwitches(List> options) { + Set switches = new HashSet<>(); + for (Map opt : options) { + Object optionName = opt.get("option_name"); + if (optionName instanceof String) { + String normalized = normalizeSwitch((String) optionName); + if (normalized != null && !normalized.isEmpty()) { + switches.add(normalized); + } + } + } + return switches; + } + + // (4) 옵션 공통 항목 평가 + private double evaluateOptionOverlap(List> baseOptions, + List> candOptions, + List reasons) { + Set baseOptionNames = new HashSet<>(); + for (Map opt : baseOptions) { + Object on = opt.get("option_name"); + if (on instanceof String) baseOptionNames.add(((String) on).trim().toLowerCase()); + } + + int commonOptionCount = 0; + for (Map opt : candOptions) { + Object on = opt.get("option_name"); + if (on instanceof String && baseOptionNames.contains(((String) on).trim().toLowerCase())) { + commonOptionCount++; + } + } + + double optionScore = Math.min(commonOptionCount / 5.0 * 0.1, 0.1); + if (optionScore > 0) { + reasons.add("options 공통 항목 " + commonOptionCount + "개 (+" + String.format("%.2f", optionScore) + ")"); + } + return optionScore; + } + + + // ---- 유틸 ---- + // 키 정규화 (공백 제거), 로그 포함 + private String normalizeKey(String key) { + return key == null ? null : key.trim(); + } + + // 키압 파싱, 로그 포함 + private Integer parsePressure(Object pressureObj) { + if (pressureObj == null) { + log.debug("parsePressure 호출: 입력 null"); + return null; + } + String str = pressureObj.toString().replaceAll("[^0-9]", ""); + if (str.isEmpty()) { + log.debug("parsePressure 호출: 숫자 추출 실패 (입력값={})", pressureObj); + return null; + } + Integer value = Integer.parseInt(str); + log.debug("parsePressure 변환: 원본='{}' -> 변환={}", pressureObj, value); + return value; + } + + // 스위치명 정규화, 로그 포함 + private String normalizeSwitch(String sw) { + if (sw == null) { + log.debug("normalizeSwitch 호출: 입력 null"); + return null; + } + String normalized = sw.trim().replace("축", "").replace(" ", "").toLowerCase(); + log.debug("normalizeSwitch 변환: 원본='{}' -> 변환='{}'", sw, normalized); + return normalized; + } + + // 스위치 계열 비교, 로그 포함 + private boolean sameSwitchFamily(String s1, String s2) { + if (s1 == null || s2 == null) { + log.debug("sameSwitchFamily 호출: 입력 null (s1={}, s2={})", s1, s2); + return false; + } + boolean result = s1.charAt(0) == s2.charAt(0); + log.debug("sameSwitchFamily 비교: s1='{}', s2='{}' -> 결과={}", s1, s2, result); + return result; + } + + // ScoredProduct 클래스 + @Getter + private static class ScoredProduct { + private final Product product; + private final double score; + private final List reasons; + + public ScoredProduct(Product product, double score, List reasons) { + this.product = product; + this.score = score; + this.reasons = reasons; + log.info("ScoredProduct 생성: 상품ID={}, 점수={}, 이유={}", product.getId(), score, String.join(", ", reasons)); + } + } + + @Getter + @RequiredArgsConstructor + public static class SimilarityResult { + private final double score; + private final List reasons; + + } + +} 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 571b706..dc7b772 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 @@ -46,6 +46,9 @@ public class ProductDocument { @Field("all_detail_images") private List allDetailImages; // + @Field("spec_table") + private Map specTable; // spec_table은 현재 비어있는 상태로 초기화 + /// 도메인 변경 public Product toDomain(){ return Product.builder() @@ -59,6 +62,7 @@ public Product toDomain(){ .detailPageUrl(detailPageUrl) .options(options) .allDetailImages(allDetailImages) + .specTable(specTable) .build(); } } diff --git a/src/main/java/site/kikihi/custom/platform/application/in/recommendation/RecommendationUseCase.java b/src/main/java/site/kikihi/custom/platform/application/in/recommendation/RecommendationUseCase.java index faa9fcb..181c941 100644 --- a/src/main/java/site/kikihi/custom/platform/application/in/recommendation/RecommendationUseCase.java +++ b/src/main/java/site/kikihi/custom/platform/application/in/recommendation/RecommendationUseCase.java @@ -1,5 +1,7 @@ package site.kikihi.custom.platform.application.in.recommendation; +import site.kikihi.custom.platform.adapter.in.web.dto.request.product.KeyboardRecommendationRequest; +import site.kikihi.custom.platform.adapter.in.web.dto.response.product.KeyboardRecommendationResponse; import site.kikihi.custom.platform.domain.product.Product; import java.util.List; import java.util.UUID; @@ -9,4 +11,10 @@ public interface RecommendationUseCase { /// 상품 추천 List getProductsByRecommendation(UUID userId); + /// 튜토리얼 키보드 추천 + List getTutorialKeyboardRecommendation(UUID userId, KeyboardRecommendationRequest request); + + /// 유사한 상품 추천 + List getSimilarProducts(UUID userId, String productId); + } diff --git a/src/main/java/site/kikihi/custom/platform/application/out/recommend/RecommendKeyboardPort.java b/src/main/java/site/kikihi/custom/platform/application/out/recommend/RecommendKeyboardPort.java new file mode 100644 index 0000000..5ffc55f --- /dev/null +++ b/src/main/java/site/kikihi/custom/platform/application/out/recommend/RecommendKeyboardPort.java @@ -0,0 +1,47 @@ +package site.kikihi.custom.platform.application.out.recommend; + +import site.kikihi.custom.platform.adapter.in.web.dto.request.product.KeyboardOptions; +import site.kikihi.custom.platform.domain.product.Product; + +import java.util.List; +import java.util.UUID; + +public interface RecommendKeyboardPort { + + // 키보드 추천 필터링 & 조회 + /** + * 키보드 추천 필터링 및 조회 + * + * @param userId 요청 사용자 식별자 (로그, 개인화 목적으로 활용 가능) + * @param size 키보드 배열(Size) 옵션 (필수) + * @param keyPressure 키압(Key Pressure) 옵션 (선택) + * @param layout 키보드 배열의 크기 ) + * @param switchType 스위치 종류(Switch Type) 옵션 (필수) + * @param soundDampener 흡음재 적용 여부 (선택) + * @param rgb RGB 적용 여부 (선택) + * @param minPrice 최소 가격 (필수) + * @param maxPrice 최대 가격 (필수) + * @return 필터링 된 추천 키보드 목록 (DTO 등 도메인 타입으로 변환 가능) + */ + List filterAndRecommendKeyboards( + UUID userId, + String size, + Integer keyPressure, + String layout, + List switchType, + String soundDampener, + String rgb, + int minPrice, + int maxPrice + ); + + // 유사한 상품 추천 + /** + * 유사한 상품 추천 + * + * @param userId 요청 사용자 식별자 (로그, 개인화 목적으로 활용 가능) + * @param productId 추천할 상품의 식별자 + * @return 유사한 상품 목록 (DTO 등 도메인 타입으로 변환 가능) + */ + List getSimilarProducts(UUID userId, String productId,Product baseProduct); +} diff --git a/src/main/java/site/kikihi/custom/platform/application/service/RecommendationService.java b/src/main/java/site/kikihi/custom/platform/application/service/RecommendationService.java index 507fcdf..681b03f 100644 --- a/src/main/java/site/kikihi/custom/platform/application/service/RecommendationService.java +++ b/src/main/java/site/kikihi/custom/platform/application/service/RecommendationService.java @@ -1,18 +1,21 @@ package site.kikihi.custom.platform.application.service; import site.kikihi.custom.global.response.ErrorCode; +import site.kikihi.custom.platform.adapter.in.web.converter.KeyboardOptionsConverter; +import site.kikihi.custom.platform.adapter.in.web.dto.request.product.KeyboardRecommendationRequest; +import site.kikihi.custom.platform.adapter.in.web.dto.response.product.KeyboardRecommendationResponse; import site.kikihi.custom.platform.application.in.recommendation.RecommendationUseCase; import site.kikihi.custom.platform.application.out.bookmark.BookmarkPort; import site.kikihi.custom.platform.application.out.bookmark.dto.TopBookmark; import site.kikihi.custom.platform.application.out.custom.CustomKeyboardPort; import site.kikihi.custom.platform.application.out.product.ProductPort; +import site.kikihi.custom.platform.application.out.recommend.RecommendKeyboardPort; import site.kikihi.custom.platform.domain.custom.CustomKeyboard; import site.kikihi.custom.platform.domain.custom.CustomKeyboardLayout; import site.kikihi.custom.platform.domain.product.Product; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - import java.util.*; @Service @@ -27,6 +30,10 @@ public class RecommendationService implements RecommendationUseCase { /// 커스텀 의존성 조회 private final CustomKeyboardPort customPort; + /// 키보드 추천 필터링 & 조회 + private final RecommendKeyboardPort recommendKeyboardPort; + + /// 추천 기준 private static final int RECOMMEND_COUNT = 8; private static final int BOOKMARK_BOUND_1 = 20; @@ -85,6 +92,64 @@ else if (bookmarkProductSize < BOOKMARK_BOUND_1) { } + /** + * 키보드 추천을 위한 필터링 로직 + * + * @param userId 유저 ID + * @param request 키보드 추천 요청 DTO + * @return 추천 상품 리스트 + */ + @Override + public List getTutorialKeyboardRecommendation(UUID userId, KeyboardRecommendationRequest request) { + if (userId == null) { + return Collections.emptyList(); + } + + List documents=recommendKeyboardPort.filterAndRecommendKeyboards( + userId, + KeyboardOptionsConverter.mapSizeToDescription(request.getSize()), + KeyboardOptionsConverter.mapKeyPressureToSpecTable(request.getKeyPressure()), + KeyboardOptionsConverter.mapLayoutToDescription(request.getLayout()), + KeyboardOptionsConverter.mapSwitchTypeToOptionNames(request.getSwitchType()), + request.getSoundDampener().getValue(), + request.getRgb().getValue(), + request.getMinPrice(), + request.getMaxPrice() + ); + + return documents.stream() + .map(KeyboardRecommendationResponse :: from) + .toList(); + + } + + /** + * 유사한 상품을 추천하는 로직입니다. + * @param userId + * @param productId + * @return + */ + @Override + public List getSimilarProducts(UUID userId, String productId) { + /// 유저가 있다면 체크, 없다면 바로 상품 조회 +// if (userId != null) { +// /// 커스텀을 제작했다면, 비슷한 특성의 상품들을 추천 +// Optional customKeyboard = customPort.loadCustomKeyboardByUserId(userId); +// +// if (customKeyboard.isPresent()) { +// return recommendForCustomUser(customKeyboard.get()); +// } +// } + + /// 상품 조회 + Product product = loadProduct(productId); + + /// 유사한 상품 필터링 + return recommendKeyboardPort.getSimilarProducts(userId, productId,product); + + } + + // ================= // 내부 함수 // =================