From 74d6d44ef9eea5fd18944aa5512032a9c518f0d3 Mon Sep 17 00:00:00 2001 From: Baguette-bbang Date: Sun, 6 Jul 2025 04:21:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B6=94=EC=B2=9C=20=ED=96=A5=EC=88=98?= =?UTF-8?q?=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../perfume/controller/PerfumeController.java | 49 ++++++ .../umc/domain/perfume/entity/Perfume.java | 10 +- .../umc/domain/perfume/entity/SourceType.java | 6 +- .../perfume/repository/PerfumeRepository.java | 6 + .../perfume/service/PerfumeService.java | 148 ++++++++++++++++++ 5 files changed, 215 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/umc/domain/perfume/controller/PerfumeController.java b/src/main/java/com/umc/domain/perfume/controller/PerfumeController.java index c960a4f..9d10e18 100644 --- a/src/main/java/com/umc/domain/perfume/controller/PerfumeController.java +++ b/src/main/java/com/umc/domain/perfume/controller/PerfumeController.java @@ -5,6 +5,7 @@ import com.umc.domain.perfume.dto.PerfumeResponseDto; import com.umc.domain.perfume.entity.SourceType; import com.umc.domain.perfume.service.PerfumeService; +import java.util.List; import com.umc.domain.user.entity.User; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -293,4 +294,52 @@ public ApiResponse deletePerfume( throw new RuntimeException("향수 삭제 중 예상하지 못한 오류가 발생했습니다."); } } + + @GetMapping("/recommend") + @Operation( + summary = "향수 추천", + description = "오디오 또는 이미지 타입에 따른 향수를 최대 10개 추천합니다. 인증 없이 사용 가능합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "향수 추천 성공", + content = @Content(schema = @Schema(implementation = PerfumeResponseDto.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 요청 (잘못된 sourceType)" + ) + }) + public ApiResponse> recommendPerfume( + @Parameter(description = "소스 타입 (AUDIO 또는 IMAGE)", required = true) + @RequestParam("sourceType") String sourceType) { + + try { + log.info("향수 추천 요청 - sourceType: {}", sourceType); + + // sourceType을 내부 enum으로 변환 + SourceType internalSourceType; + if ("AUDIO".equalsIgnoreCase(sourceType)) { + internalSourceType = SourceType.RECOMMEND_AUDIO; + } else if ("IMAGE".equalsIgnoreCase(sourceType)) { + internalSourceType = SourceType.RECOMMEND_IMAGE; + } else { + throw new RuntimeException("잘못된 sourceType입니다. AUDIO 또는 IMAGE만 사용 가능합니다."); + } + + List response = perfumeService.recommendPerfumes(internalSourceType); + + log.info("향수 추천 성공 - sourceType: {}, 추천 개수: {}", sourceType, response.size()); + + return ApiResponse.success(response); + + } catch (RuntimeException e) { + log.error("향수 추천 실패 - sourceType: {}, 오류: {}", sourceType, e.getMessage()); + throw e; + } catch (Exception e) { + log.error("향수 추천 실패 - 시스템 오류: ", e); + throw new RuntimeException("향수 추천 중 예상하지 못한 오류가 발생했습니다."); + } + } } \ No newline at end of file diff --git a/src/main/java/com/umc/domain/perfume/entity/Perfume.java b/src/main/java/com/umc/domain/perfume/entity/Perfume.java index 60e47b7..2da8b41 100644 --- a/src/main/java/com/umc/domain/perfume/entity/Perfume.java +++ b/src/main/java/com/umc/domain/perfume/entity/Perfume.java @@ -5,11 +5,17 @@ import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; @Entity @Table(name = "perfume") @Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder public class Perfume extends BaseEntity { @Enumerated(EnumType.STRING) @@ -23,8 +29,8 @@ public class Perfume extends BaseEntity { private String url; // 소스 URL (오디오/이미지 파일 경로) @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; // 향수를 생성한 사용자 + @JoinColumn(name = "user_id", nullable = true) + private User user; // 향수를 생성한 사용자 (추천 향수는 null 가능) // BaseEntity에서 이미 id, createdAt, updatedAt을 상속받음 } \ No newline at end of file diff --git a/src/main/java/com/umc/domain/perfume/entity/SourceType.java b/src/main/java/com/umc/domain/perfume/entity/SourceType.java index fa56bf6..e35c09e 100644 --- a/src/main/java/com/umc/domain/perfume/entity/SourceType.java +++ b/src/main/java/com/umc/domain/perfume/entity/SourceType.java @@ -1,6 +1,8 @@ package com.umc.domain.perfume.entity; public enum SourceType { - AUDIO, // 오디오 소스 - IMAGE // 이미지 소스 + AUDIO, // 오디오 소스 + IMAGE, // 이미지 소스 + RECOMMEND_AUDIO, // 추천 오디오 소스 + RECOMMEND_IMAGE // 추천 이미지 소스 } \ No newline at end of file diff --git a/src/main/java/com/umc/domain/perfume/repository/PerfumeRepository.java b/src/main/java/com/umc/domain/perfume/repository/PerfumeRepository.java index a670cad..5e6587b 100644 --- a/src/main/java/com/umc/domain/perfume/repository/PerfumeRepository.java +++ b/src/main/java/com/umc/domain/perfume/repository/PerfumeRepository.java @@ -2,6 +2,7 @@ import com.umc.domain.perfume.entity.Perfume; import com.umc.domain.perfume.entity.SourceType; +import java.util.List; import com.umc.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -57,4 +58,9 @@ List findByCreatedAtBetween(@Param("startDate") LocalDateTime startDate */ @Query("SELECT COUNT(p) > 0 FROM Perfume p WHERE p.user.id = :userId") boolean existsByUserId(@Param("userId") Long userId); + + /** + * 소스 타입별 최근 향수 10개 조회 (추천용) + */ + List findTop10BySourceTypeOrderByCreatedAtDesc(SourceType sourceType); } \ No newline at end of file diff --git a/src/main/java/com/umc/domain/perfume/service/PerfumeService.java b/src/main/java/com/umc/domain/perfume/service/PerfumeService.java index eef35b1..307995c 100644 --- a/src/main/java/com/umc/domain/perfume/service/PerfumeService.java +++ b/src/main/java/com/umc/domain/perfume/service/PerfumeService.java @@ -3,6 +3,8 @@ import com.umc.domain.perfume.dto.PerfumeResponseDto; import com.umc.domain.perfume.entity.Perfume; import com.umc.domain.perfume.entity.SourceType; +import java.util.List; +import java.util.ArrayList; import com.umc.domain.perfume.repository.PerfumeRepository; import com.umc.domain.user.entity.User; import com.umc.domain.user.repository.UserRepository; @@ -193,4 +195,150 @@ public void deletePerfume(Long id, User user) { perfumeRepository.delete(perfume); log.info("향수 삭제 완료 - 향수 ID: {}, 사용자: {}", id, user.getNickname()); } + + /** + * 향수 추천 (인증 없이 사용 가능) - 최대 10개 반환 + */ + @Transactional(readOnly = true) + public List recommendPerfumes(SourceType sourceType) { + if (sourceType == null) { + throw new RuntimeException("sourceType이 필요합니다."); + } + + if (sourceType != SourceType.RECOMMEND_AUDIO && sourceType != SourceType.RECOMMEND_IMAGE) { + throw new RuntimeException("추천 API는 RECOMMEND_AUDIO 또는 RECOMMEND_IMAGE 타입만 사용 가능합니다."); + } + + // 추천 로직: 추천 타입에 해당하는 향수들을 가져옴 + List recentPerfumes = perfumeRepository.findTop10BySourceTypeOrderByCreatedAtDesc(sourceType); + + if (recentPerfumes.isEmpty()) { + // 추천할 향수가 없으면 기본 향수들 생성 + return createDefaultRecommendations(sourceType); + } + + // 향수들을 DTO로 변환 + List recommendations = recentPerfumes.stream() + .map(PerfumeResponseDto::from) + .toList(); + + log.info("향수 추천 완료 - 타입: {}, 추천 개수: {}", sourceType, recommendations.size()); + + return recommendations; + } + + /** + * 기본 추천 향수들 생성 (추천할 향수가 없을 때) - 최대 10개 + */ + private List createDefaultRecommendations(SourceType sourceType) { + List defaultRecommendations = new ArrayList<>(); + + if (sourceType == SourceType.RECOMMEND_AUDIO) { + // 추천 오디오 타입 기본 향수들 + String[] audioDescriptions = { + """ + { + "type": "AUDIO", + "top": ["레몬", "라임", "베르가못"], + "middle": ["라벤더", "로즈마리", "민트"], + "base": ["머스크", "우드", "앰버"], + "interpretation": "신선하고 상쾌한 시트러스 향이 중간의 허브 향과 조화를 이루며, 따뜻한 베이스 노트가 안정감을 더합니다.", + "summary": "상쾌하고 활기찬 향수", + "title": "상쾌한 아침 : 활기찬 에너지", + "fileDescription": "오디오 파일에서 추출한 상쾌하고 활기찬 분위기를 담은 향수입니다." + } + """, + """ + { + "type": "AUDIO", + "top": ["오렌지", "만다린", "그레이프프루트"], + "middle": ["재스민", "네롤리", "베르가못"], + "base": ["샌달우드", "파츌리", "머스크"], + "interpretation": "달콤하고 상쾌한 시트러스 향이 중간의 플로럴 향과 조화를 이루며, 깊이 있는 우디 베이스가 우아함을 더합니다.", + "summary": "달콤하고 상쾌한 향수", + "title": "달콤한 오후 : 상쾌한 기분", + "fileDescription": "오디오 파일에서 추출한 달콤하고 상쾌한 분위기를 담은 향수입니다." + } + """, + """ + { + "type": "AUDIO", + "top": ["베르가못", "핑크 페퍼", "카다몬"], + "middle": ["로즈", "피오니", "일랑일랑"], + "base": ["앰버", "바닐라", "머스크"], + "interpretation": "스파이시한 향이 중간의 로맨틱한 플로럴 향과 조화를 이루며, 달콤한 베이스 노트가 매력적입니다.", + "summary": "스파이시하고 로맨틱한 향수", + "title": "스파이시한 저녁 : 매력적인 분위기", + "fileDescription": "오디오 파일에서 추출한 스파이시하고 로맨틱한 분위기를 담은 향수입니다." + } + """ + }; + + for (int i = 0; i < audioDescriptions.length; i++) { + Perfume defaultPerfume = Perfume.builder() + .sourceType(sourceType) + .description(audioDescriptions[i]) + .url("/virtual/recommendation-audio-" + (i + 1) + ".json") + .user(null) // 추천 향수는 사용자와 연결되지 않음 + .build(); + + defaultRecommendations.add(PerfumeResponseDto.from(defaultPerfume)); + } + } else { + // 추천 이미지 타입 기본 향수들 + String[] imageDescriptions = { + """ + { + "type": "IMAGE", + "top": ["자스민", "피오니", "로즈"], + "middle": ["바닐라", "일랑일랑", "오키드"], + "base": ["샌달우드", "파츌리", "머스크"], + "interpretation": "우아하고 로맨틱한 플로럴 향이 중간의 달콤한 향과 조화를 이루며, 깊이 있는 우디 베이스가 신비로움을 더합니다.", + "summary": "우아하고 로맨틱한 향수", + "title": "로맨틱한 저녁 : 우아한 분위기", + "fileDescription": "이미지에서 추출한 우아하고 로맨틱한 분위기를 담은 향수입니다." + } + """, + """ + { + "type": "IMAGE", + "top": ["라벤더", "로즈마리", "세이지"], + "middle": ["재스민", "네롤리", "카모마일"], + "base": ["머스크", "우드", "앰버"], + "interpretation": "차분하고 평화로운 허브 향이 중간의 부드러운 플로럴 향과 조화를 이루며, 따뜻한 베이스 노트가 안정감을 더합니다.", + "summary": "차분하고 평화로운 향수", + "title": "평화로운 아침 : 차분한 분위기", + "fileDescription": "이미지에서 추출한 차분하고 평화로운 분위기를 담은 향수입니다." + } + """, + """ + { + "type": "IMAGE", + "top": ["베르가못", "핑크 페퍼", "카다몬"], + "middle": ["로즈", "피오니", "일랑일랑"], + "base": ["앰버", "바닐라", "머스크"], + "interpretation": "스파이시하고 매력적인 향이 중간의 로맨틱한 플로럴 향과 조화를 이루며, 달콤한 베이스 노트가 매력적입니다.", + "summary": "스파이시하고 매력적인 향수", + "title": "매력적인 밤 : 스파이시한 분위기", + "fileDescription": "이미지에서 추출한 스파이시하고 매력적인 분위기를 담은 향수입니다." + } + """ + }; + + for (int i = 0; i < imageDescriptions.length; i++) { + Perfume defaultPerfume = Perfume.builder() + .sourceType(sourceType) + .description(imageDescriptions[i]) + .url("/virtual/recommendation-image-" + (i + 1) + ".json") + .user(null) // 추천 향수는 사용자와 연결되지 않음 + .build(); + + defaultRecommendations.add(PerfumeResponseDto.from(defaultPerfume)); + } + } + + log.info("기본 추천 향수들 생성 - 타입: {}, 개수: {}", sourceType, defaultRecommendations.size()); + + return defaultRecommendations; + } } \ No newline at end of file