diff --git a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java index 694623fe..84b4490e 100644 --- a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java @@ -87,7 +87,7 @@ public AdminOverviewResponse getOverview(AdminOverviewRequest request) { long totalUsers = userRepository.count(); long totalProducts = productRepository.count(); long totalFundings = fundingRepository.count(); - long artistCount = userRepository.findAll().stream().filter(User::isArtist).count(); + long artistCount = userRepository.countArtists(); // 최적화: findAll() 대신 COUNT 쿼리 사용 // 2. 오늘 날짜 기준 통계 LocalDate today = LocalDate.now(); diff --git a/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java b/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java index 4a2ced65..7ea9684b 100644 --- a/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java +++ b/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java @@ -79,11 +79,11 @@ public ProductListResponse findProducts( )); } - // QueryDSL로 엔티티 조회 + THUMBNAIL join + // QueryDSL로 엔티티 조회 + 이미지 eager loading var query = queryFactory .select(p) .from(p) - .leftJoin(p.images, img).on(img.fileType.eq(FileType.THUMBNAIL)) + .leftJoin(p.images, img).fetchJoin() .where(builder) .distinct(); diff --git a/src/main/java/com/back/domain/product/product/repository/ProductRepository.java b/src/main/java/com/back/domain/product/product/repository/ProductRepository.java index 8ae8ceb2..6f8b1568 100644 --- a/src/main/java/com/back/domain/product/product/repository/ProductRepository.java +++ b/src/main/java/com/back/domain/product/product/repository/ProductRepository.java @@ -18,7 +18,14 @@ public interface ProductRepository extends JpaRepository, ProductCustomRepository, JpaSpecificationExecutor { Optional findByProductUuid(UUID productUuid); - + + // 태그 및 이미지 정보를 포함한 상품 조회 (추천 시스템용) + @Query("SELECT DISTINCT p FROM Product p " + + "LEFT JOIN FETCH p.productTags pt " + + "LEFT JOIN FETCH pt.tag " + + "LEFT JOIN FETCH p.images " + + "WHERE p.productUuid = :productUuid") + Optional findByProductUuidWithTags(@Param("productUuid") UUID productUuid); // 재고 감소용 - Pessimistic Write Lock (동시성 제어) @Lock(LockModeType.PESSIMISTIC_WRITE) diff --git a/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java b/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java index 16eb9610..b732bc05 100644 --- a/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java +++ b/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java @@ -59,30 +59,28 @@ public class RecommendationController { example = """ { "resultCode": "200", - "msg": "5개 상품을 추천했습니다", - "data": { - "recommendations": [ - { - "rank": 1, - "matchScore": 2.4, - "product": { - "productUuid": "550e8400-...", - "imageUrl": "https://...", - "brandName": "문구브랜드", - "name": "부드러운 4B 연필", - "price": 15000, - "discountRate": 10, - "discountPrice": 13500, - "rating": 4.5 - }, - "matchedTags": [ - {"name": "부드러운", "yourScore": 0.9}, - {"name": "실용적인", "yourScore": 0.8} - ], - "reason": "'부드러운·실용적인·데일리' 선호와 일치" - } - ] - } + "msg": "3개 상품을 추천했습니다", + "data": [ + { + "rank": 1, + "matchScore": 2.4, + "product": { + "productUuid": "550e8400-...", + "imageUrl": "https://...", + "brandName": "문구브랜드", + "name": "부드러운 4B 연필", + "price": 15000, + "discountRate": 10, + "discountPrice": 13500, + "rating": 4.5 + }, + "matchedTags": [ + {"name": "부드러운", "yourScore": 0.9}, + {"name": "실용적인", "yourScore": 0.8} + ], + "reason": "'부드러운·실용적인·데일리' 선호와 일치" + } + ] } """ ) @@ -90,86 +88,153 @@ public class RecommendationController { ) } ) - public ResponseEntity> matchProducts( + public ResponseEntity>> matchProducts( @Valid @RequestBody PreferenceRequest request) { - + try { log.info("===== 추천 요청 시작 ====="); + log.info("🔵 요청 전체: {}", request); log.info("선호 태그: {}", request.preferences()); log.info("가격: {}-{}", request.minPrice(), request.maxPrice()); - - // 1. 상위 선호 태그 추출 (점수 0.3 이상) + + // 1. 입력값 유효성 검증 + if (request.preferences() == null || request.preferences().isEmpty()) { + log.warn("선호 태그가 비어있음"); + return ResponseEntity.ok(RsData.of("400", + "선호 태그 정보가 필요합니다", + List.of())); + } + + // 가격 범위 검증 + int minPrice = Optional.ofNullable(request.minPrice()).orElse(0); + int maxPrice = Optional.ofNullable(request.maxPrice()).orElse(9_999_999); + + if (minPrice < 0 || maxPrice < 0) { + log.warn("잘못된 가격 범위: min={}, max={}", minPrice, maxPrice); + return ResponseEntity.ok(RsData.of("400", + "가격은 0 이상이어야 합니다", + List.of())); + } + + if (minPrice > maxPrice) { + log.warn("최소 가격이 최대 가격보다 큼: min={}, max={}", minPrice, maxPrice); + return ResponseEntity.ok(RsData.of("400", + "최소 가격은 최대 가격보다 클 수 없습니다", + List.of())); + } + + // 2. 상위 선호 태그 추출 (점수 0.3 이상) List topTagNames = request.preferences().entrySet().stream() .filter(entry -> entry.getValue() >= 0.3) // 0.5 → 0.3으로 낮춤 .sorted(Map.Entry.comparingByValue().reversed()) .map(Map.Entry::getKey) .toList(); - + log.info("선호도 0.3 이상 태그: {}", topTagNames); - + if (topTagNames.isEmpty()) { log.warn("선호도 0.3 이상인 태그가 없음"); - return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다", - new MatchResponse(List.of()))); + return ResponseEntity.ok(RsData.of("200", + "선호도가 충분히 높은 태그가 없습니다. 테스트를 다시 진행해주세요.", + List.of())); + } + + // 3. 태그명 → 태그ID 변환 + List tagIds; + try { + tagIds = tagDictionary.toIds(topTagNames); + log.info("변환된 태그 ID: {}", tagIds); + } catch (IllegalArgumentException e) { + log.error("태그 변환 중 오류: {}", e.getMessage()); + return ResponseEntity.ok(RsData.of("400", + "잘못된 태그 정보입니다: " + e.getMessage(), + List.of())); + } catch (Exception e) { + log.error("태그 변환 중 예상치 못한 오류", e); + return ResponseEntity.ok(RsData.of("500", + "태그 처리 중 오류가 발생했습니다", + List.of())); } - - // 2. 태그명 → 태그ID 변환 - List tagIds = tagDictionary.toIds(topTagNames); - - log.info("변환된 태그 ID: {}", tagIds); - + if (tagIds.isEmpty()) { log.warn("유효한 태그 ID를 찾을 수 없음. 입력된 태그명: {}", topTagNames); - return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다 (태그를 찾을 수 없음)", - new MatchResponse(List.of()))); + return ResponseEntity.ok(RsData.of("200", + "해당하는 상품 태그를 찾을 수 없습니다", + List.of())); } - - // 3. 후보 상품 조회 (충분한 후보군 확보 후 상위 3개 선택) + + // 4. 후보 상품 조회 (충분한 후보군 확보 후 상위 3개 선택) int page = 0; // 첫 페이지만 int size = 50; // 50개 후보 조회 → 점수 계산 → 상위 3개 선택 - int minPrice = Optional.ofNullable(request.minPrice()).orElse(0); - int maxPrice = Optional.ofNullable(request.maxPrice()).orElse(9_999_999); - + log.info("후보군 조회: {}개 (이 중 최적 3개 선택)", size); log.info("가격 필터: {}원 ~ {}원", minPrice, maxPrice); - + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createDate")); - + // 기존 findProducts 메서드 활용 (태그 필터링 포함) - ProductListResponse response = productRepository.findProducts( - null, // categoryId: 전체 카테고리 - tagIds, // 선호 태그로 필터링 - minPrice, // 최소 가격 - maxPrice, // 최대 가격 - null, // deliveryType: 전체 - "newest", // 신상품순 - pageable - ); - - log.info("findProducts 결과: {}개 상품", response.products().size()); - + ProductListResponse response; + try { + response = productRepository.findProducts( + null, // categoryId: 전체 카테고리 + tagIds, // 선호 태그로 필터링 + minPrice, // 최소 가격 + maxPrice, // 최대 가격 + null, // deliveryType: 전체 + "newest", // 신상품순 + pageable + ); + log.info("findProducts 결과: {}개 상품", response.products().size()); + } catch (Exception e) { + log.error("상품 조회 중 오류 발생", e); + return ResponseEntity.ok(RsData.of("500", + "상품 조회 중 오류가 발생했습니다", + List.of())); + } + + if (response.products().isEmpty()) { + log.warn("조건에 맞는 상품이 없음 (findProducts 단계)"); + return ResponseEntity.ok(RsData.of("200", + "조건에 맞는 상품이 없습니다. 가격 범위를 조정해보세요.", + List.of())); + } + // ProductInfo → Product 변환이 필요하므로, UUID로 다시 조회 (태그 포함) - List filtered = response.products().stream() - .map(productInfo -> { - Optional product = productRepository.findByProductUuidWithTags(productInfo.productUuid()); - if (product.isEmpty()) { - log.warn("상품 UUID로 조회 실패: {}", productInfo.productUuid()); - } - return product; - }) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); - - log.info("UUID 재조회 결과: {}개 상품", filtered.size()); - + List filtered; + try { + filtered = response.products().stream() + .map(productInfo -> { + try { + Optional product = productRepository.findByProductUuidWithTags(productInfo.productUuid()); + if (product.isEmpty()) { + log.warn("상품 UUID로 조회 실패: {}", productInfo.productUuid()); + } + return product; + } catch (Exception e) { + log.error("상품 UUID 조회 중 오류: {}", productInfo.productUuid(), e); + return Optional.empty(); + } + }) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + log.info("UUID 재조회 결과: {}개 상품", filtered.size()); + } catch (Exception e) { + log.error("상품 상세 조회 중 오류 발생", e); + return ResponseEntity.ok(RsData.of("500", + "상품 상세 정보 조회 중 오류가 발생했습니다", + List.of())); + } + if (filtered.isEmpty()) { - log.warn("조건에 맞는 상품이 없음"); - return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다", - new MatchResponse(List.of()))); + log.warn("조건에 맞는 상품이 없음 (UUID 재조회 단계)"); + return ResponseEntity.ok(RsData.of("200", + "조건에 맞는 상품이 없습니다", + List.of())); } - - // 4. 매칭 스코어 계산 및 정렬 + + // 5. 매칭 스코어 계산 및 정렬 List ranked; try { log.info("매칭 스코어 계산 시작..."); @@ -179,40 +244,67 @@ public ResponseEntity> matchProducts( request.specPrefs() ); log.info("✅ 매칭 스코어 계산 완료: {}개", ranked.size()); + } catch (IllegalArgumentException e) { + log.error("❌ 매칭 스코어 계산 중 잘못된 입력: {}", e.getMessage()); + return ResponseEntity.ok(RsData.of("400", + "추천 계산 중 오류: " + e.getMessage(), + List.of())); + } catch (NullPointerException e) { + log.error("❌ 매칭 스코어 계산 중 null 값 발견", e); + return ResponseEntity.ok(RsData.of("500", + "추천 계산 중 데이터 오류가 발생했습니다", + List.of())); } catch (Exception e) { log.error("❌ 매칭 스코어 계산 중 오류 발생", e); - return ResponseEntity.ok(RsData.of("500", - "추천 시스템 오류: " + e.getMessage(), - new MatchResponse(List.of()))); + return ResponseEntity.ok(RsData.of("500", + "추천 계산 중 오류가 발생했습니다", + List.of())); + } + + if (ranked == null || ranked.isEmpty()) { + log.warn("매칭 스코어 계산 결과가 비어있음"); + return ResponseEntity.ok(RsData.of("200", + "추천할 수 있는 상품이 없습니다", + List.of())); } - - // 5. 상위 3개만 추출 (프론트 요구사항) + + // 6. 상위 3개만 추출 (프론트 요구사항) final int RECOMMENDATION_LIMIT = 3; List topN = ranked.stream() .limit(RECOMMENDATION_LIMIT) .toList(); - + log.info("===== 최종 추천: {}개 상품 (고정) =====", topN.size()); - - MatchResponse matchResponse = new MatchResponse(topN); - RsData result = RsData.of("200", - topN.size() + "개 상품을 추천했습니다", - matchResponse); - + + RsData> result = RsData.of("200", + topN.size() + "개 상품을 추천했습니다", + topN); + log.info("🎉 응답 반환 준비 완료"); - log.info("Response: resultCode={}, msg={}, data.size={}", + log.info("✅ Response: resultCode={}, msg={}, data.size={}", result.resultCode(), result.msg(), topN.size()); - + log.info("🔵 최종 응답 JSON 미리보기: {}", result); + return ResponseEntity.ok(result); - + + } catch (IllegalArgumentException e) { + log.error("❌ 잘못된 입력값 오류", e); + return ResponseEntity.ok(RsData.of("400", + "잘못된 요청입니다: " + e.getMessage(), + List.of())); + } catch (NullPointerException e) { + log.error("❌ Null 값 참조 오류", e); + return ResponseEntity.ok(RsData.of("500", + "데이터 처리 중 오류가 발생했습니다", + List.of())); } catch (Exception e) { log.error("❌❌❌ RecommendationController에서 예상치 못한 오류 발생 ❌❌❌", e); log.error("오류 타입: {}", e.getClass().getName()); log.error("오류 메시지: {}", e.getMessage()); - - return ResponseEntity.ok(RsData.of("500", - "추천 시스템에 문제가 발생했습니다: " + e.getMessage(), - new MatchResponse(List.of()))); + + return ResponseEntity.ok(RsData.of("500", + "추천 시스템에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.", + List.of())); } } } diff --git a/src/main/java/com/back/domain/user/repository/UserRepository.java b/src/main/java/com/back/domain/user/repository/UserRepository.java index bac907b0..74bcdd9d 100644 --- a/src/main/java/com/back/domain/user/repository/UserRepository.java +++ b/src/main/java/com/back/domain/user/repository/UserRepository.java @@ -72,4 +72,10 @@ java.util.List findDaily @org.springframework.data.repository.query.Param("startDate") java.time.LocalDateTime startDate, @org.springframework.data.repository.query.Param("endDate") java.time.LocalDateTime endDate ); + + /** + * 관리자 대시보드 - 전체 작가 수 조회 (최적화) + */ + @org.springframework.data.jpa.repository.Query("SELECT COUNT(u) FROM User u WHERE u.role = com.back.domain.user.entity.Role.ARTIST AND u.isArtistVerified = true") + long countArtists(); }