|
| 1 | +package com.back.domain.recommendation.controller; |
| 2 | + |
| 3 | +import com.back.domain.product.product.dto.response.ProductListResponse; |
| 4 | +import com.back.domain.product.product.entity.Product; |
| 5 | +import com.back.domain.product.product.repository.ProductRepository; |
| 6 | +import com.back.domain.recommendation.dto.request.PreferenceRequest; |
| 7 | +import com.back.domain.recommendation.dto.response.MatchResponse; |
| 8 | +import com.back.domain.recommendation.dto.response.RecommendedItem; |
| 9 | +import com.back.domain.recommendation.service.MatcherService; |
| 10 | +import com.back.domain.recommendation.service.TagDictionary; |
| 11 | +import com.back.global.rsData.RsData; |
| 12 | +import io.swagger.v3.oas.annotations.Operation; |
| 13 | +import io.swagger.v3.oas.annotations.media.Content; |
| 14 | +import io.swagger.v3.oas.annotations.media.Schema; |
| 15 | +import io.swagger.v3.oas.annotations.responses.ApiResponse; |
| 16 | +import io.swagger.v3.oas.annotations.tags.Tag; |
| 17 | +import jakarta.validation.Valid; |
| 18 | +import lombok.RequiredArgsConstructor; |
| 19 | +import lombok.extern.slf4j.Slf4j; |
| 20 | +import org.springframework.data.domain.PageRequest; |
| 21 | +import org.springframework.data.domain.Pageable; |
| 22 | +import org.springframework.data.domain.Sort; |
| 23 | +import org.springframework.http.ResponseEntity; |
| 24 | +import org.springframework.web.bind.annotation.PostMapping; |
| 25 | +import org.springframework.web.bind.annotation.RequestBody; |
| 26 | +import org.springframework.web.bind.annotation.RequestMapping; |
| 27 | +import org.springframework.web.bind.annotation.RestController; |
| 28 | + |
| 29 | +import java.util.List; |
| 30 | +import java.util.Map; |
| 31 | +import java.util.Optional; |
| 32 | + |
| 33 | +/** |
| 34 | + * 취향 기반 상품 추천 컨트롤러 |
| 35 | + */ |
| 36 | +@RestController |
| 37 | +@RequestMapping("/api/recommendations") |
| 38 | +@RequiredArgsConstructor |
| 39 | +@Slf4j |
| 40 | +@Tag(name = "상품 추천", description = "취향 테스트 기반 상품 추천 API") |
| 41 | +public class RecommendationController { |
| 42 | + |
| 43 | + private final ProductRepository productRepository; |
| 44 | + private final TagDictionary tagDictionary; |
| 45 | + private final MatcherService matcherService; |
| 46 | + |
| 47 | + @PostMapping("/match") |
| 48 | + @Operation( |
| 49 | + summary = "취향 기반 상품 매칭", |
| 50 | + description = "사용자의 취향 테스트 결과(태그별 점수)를 기반으로 상품을 추천합니다. " + |
| 51 | + "7~9문항의 테스트 결과를 태그별 점수로 변환하여 요청하세요.", |
| 52 | + responses = { |
| 53 | + @ApiResponse( |
| 54 | + responseCode = "200", |
| 55 | + description = "추천 성공", |
| 56 | + content = @Content( |
| 57 | + mediaType = "application/json", |
| 58 | + schema = @Schema( |
| 59 | + example = """ |
| 60 | + { |
| 61 | + "resultCode": "200", |
| 62 | + "msg": "5개 상품을 추천했습니다", |
| 63 | + "data": { |
| 64 | + "recommendations": [ |
| 65 | + { |
| 66 | + "rank": 1, |
| 67 | + "matchScore": 2.4, |
| 68 | + "product": { |
| 69 | + "productUuid": "550e8400-...", |
| 70 | + "imageUrl": "https://...", |
| 71 | + "brandName": "문구브랜드", |
| 72 | + "name": "부드러운 4B 연필", |
| 73 | + "price": 15000, |
| 74 | + "discountRate": 10, |
| 75 | + "discountPrice": 13500, |
| 76 | + "rating": 4.5 |
| 77 | + }, |
| 78 | + "matchedTags": [ |
| 79 | + {"name": "부드러운", "yourScore": 0.9}, |
| 80 | + {"name": "실용적인", "yourScore": 0.8} |
| 81 | + ], |
| 82 | + "reason": "'부드러운·실용적인·데일리' 선호와 일치" |
| 83 | + } |
| 84 | + ] |
| 85 | + } |
| 86 | + } |
| 87 | + """ |
| 88 | + ) |
| 89 | + ) |
| 90 | + ) |
| 91 | + } |
| 92 | + ) |
| 93 | + public ResponseEntity<RsData<MatchResponse>> matchProducts( |
| 94 | + @Valid @RequestBody PreferenceRequest request) { |
| 95 | + |
| 96 | + try { |
| 97 | + log.info("===== 추천 요청 시작 ====="); |
| 98 | + log.info("선호 태그: {}", request.preferences()); |
| 99 | + log.info("가격: {}-{}", request.minPrice(), request.maxPrice()); |
| 100 | + |
| 101 | + // 1. 상위 선호 태그 추출 (점수 0.3 이상) |
| 102 | + List<String> topTagNames = request.preferences().entrySet().stream() |
| 103 | + .filter(entry -> entry.getValue() >= 0.3) // 0.5 → 0.3으로 낮춤 |
| 104 | + .sorted(Map.Entry.<String, Double>comparingByValue().reversed()) |
| 105 | + .map(Map.Entry::getKey) |
| 106 | + .toList(); |
| 107 | + |
| 108 | + log.info("선호도 0.3 이상 태그: {}", topTagNames); |
| 109 | + |
| 110 | + if (topTagNames.isEmpty()) { |
| 111 | + log.warn("선호도 0.3 이상인 태그가 없음"); |
| 112 | + return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다", |
| 113 | + new MatchResponse(List.of()))); |
| 114 | + } |
| 115 | + |
| 116 | + // 2. 태그명 → 태그ID 변환 |
| 117 | + List<Long> tagIds = tagDictionary.toIds(topTagNames); |
| 118 | + |
| 119 | + log.info("변환된 태그 ID: {}", tagIds); |
| 120 | + |
| 121 | + if (tagIds.isEmpty()) { |
| 122 | + log.warn("유효한 태그 ID를 찾을 수 없음. 입력된 태그명: {}", topTagNames); |
| 123 | + return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다 (태그를 찾을 수 없음)", |
| 124 | + new MatchResponse(List.of()))); |
| 125 | + } |
| 126 | + |
| 127 | + // 3. 후보 상품 조회 (충분한 후보군 확보 후 상위 3개 선택) |
| 128 | + int page = 0; // 첫 페이지만 |
| 129 | + int size = 50; // 50개 후보 조회 → 점수 계산 → 상위 3개 선택 |
| 130 | + int minPrice = Optional.ofNullable(request.minPrice()).orElse(0); |
| 131 | + int maxPrice = Optional.ofNullable(request.maxPrice()).orElse(9_999_999); |
| 132 | + |
| 133 | + log.info("후보군 조회: {}개 (이 중 최적 3개 선택)", size); |
| 134 | + log.info("가격 필터: {}원 ~ {}원", minPrice, maxPrice); |
| 135 | + |
| 136 | + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createDate")); |
| 137 | + |
| 138 | + // 기존 findProducts 메서드 활용 (태그 필터링 포함) |
| 139 | + ProductListResponse response = productRepository.findProducts( |
| 140 | + null, // categoryId: 전체 카테고리 |
| 141 | + tagIds, // 선호 태그로 필터링 |
| 142 | + minPrice, // 최소 가격 |
| 143 | + maxPrice, // 최대 가격 |
| 144 | + null, // deliveryType: 전체 |
| 145 | + "newest", // 신상품순 |
| 146 | + pageable |
| 147 | + ); |
| 148 | + |
| 149 | + log.info("findProducts 결과: {}개 상품", response.products().size()); |
| 150 | + |
| 151 | + // ProductInfo → Product 변환이 필요하므로, UUID로 다시 조회 (태그 포함) |
| 152 | + List<Product> filtered = response.products().stream() |
| 153 | + .map(productInfo -> { |
| 154 | + Optional<Product> product = productRepository.findByProductUuidWithTags(productInfo.productUuid()); |
| 155 | + if (product.isEmpty()) { |
| 156 | + log.warn("상품 UUID로 조회 실패: {}", productInfo.productUuid()); |
| 157 | + } |
| 158 | + return product; |
| 159 | + }) |
| 160 | + .filter(Optional::isPresent) |
| 161 | + .map(Optional::get) |
| 162 | + .toList(); |
| 163 | + |
| 164 | + log.info("UUID 재조회 결과: {}개 상품", filtered.size()); |
| 165 | + |
| 166 | + if (filtered.isEmpty()) { |
| 167 | + log.warn("조건에 맞는 상품이 없음"); |
| 168 | + return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다", |
| 169 | + new MatchResponse(List.of()))); |
| 170 | + } |
| 171 | + |
| 172 | + // 4. 매칭 스코어 계산 및 정렬 |
| 173 | + List<RecommendedItem> ranked; |
| 174 | + try { |
| 175 | + log.info("매칭 스코어 계산 시작..."); |
| 176 | + ranked = matcherService.scoreAndRank( |
| 177 | + filtered, |
| 178 | + request.preferences(), |
| 179 | + request.specPrefs() |
| 180 | + ); |
| 181 | + log.info("✅ 매칭 스코어 계산 완료: {}개", ranked.size()); |
| 182 | + } catch (Exception e) { |
| 183 | + log.error("❌ 매칭 스코어 계산 중 오류 발생", e); |
| 184 | + return ResponseEntity.ok(RsData.of("500", |
| 185 | + "추천 시스템 오류: " + e.getMessage(), |
| 186 | + new MatchResponse(List.of()))); |
| 187 | + } |
| 188 | + |
| 189 | + // 5. 상위 3개만 추출 (프론트 요구사항) |
| 190 | + final int RECOMMENDATION_LIMIT = 3; |
| 191 | + List<RecommendedItem> topN = ranked.stream() |
| 192 | + .limit(RECOMMENDATION_LIMIT) |
| 193 | + .toList(); |
| 194 | + |
| 195 | + log.info("===== 최종 추천: {}개 상품 (고정) =====", topN.size()); |
| 196 | + |
| 197 | + MatchResponse matchResponse = new MatchResponse(topN); |
| 198 | + RsData<MatchResponse> result = RsData.of("200", |
| 199 | + topN.size() + "개 상품을 추천했습니다", |
| 200 | + matchResponse); |
| 201 | + |
| 202 | + log.info("🎉 응답 반환 준비 완료"); |
| 203 | + log.info("Response: resultCode={}, msg={}, data.size={}", |
| 204 | + result.resultCode(), result.msg(), topN.size()); |
| 205 | + |
| 206 | + return ResponseEntity.ok(result); |
| 207 | + |
| 208 | + } catch (Exception e) { |
| 209 | + log.error("❌❌❌ RecommendationController에서 예상치 못한 오류 발생 ❌❌❌", e); |
| 210 | + log.error("오류 타입: {}", e.getClass().getName()); |
| 211 | + log.error("오류 메시지: {}", e.getMessage()); |
| 212 | + |
| 213 | + return ResponseEntity.ok(RsData.of("500", |
| 214 | + "추천 시스템에 문제가 발생했습니다: " + e.getMessage(), |
| 215 | + new MatchResponse(List.of()))); |
| 216 | + } |
| 217 | + } |
| 218 | +} |
0 commit comments