Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -293,4 +294,52 @@ public ApiResponse<String> 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<List<PerfumeResponseDto>> 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<PerfumeResponseDto> 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("향수 추천 중 예상하지 못한 오류가 발생했습니다.");
}
}
}
10 changes: 8 additions & 2 deletions src/main/java/com/umc/domain/perfume/entity/Perfume.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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을 상속받음
}
6 changes: 4 additions & 2 deletions src/main/java/com/umc/domain/perfume/entity/SourceType.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.umc.domain.perfume.entity;

public enum SourceType {
AUDIO, // 오디오 소스
IMAGE // 이미지 소스
AUDIO, // 오디오 소스
IMAGE, // 이미지 소스
RECOMMEND_AUDIO, // 추천 오디오 소스
RECOMMEND_IMAGE // 추천 이미지 소스
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,4 +58,9 @@ List<Perfume> 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<Perfume> findTop10BySourceTypeOrderByCreatedAtDesc(SourceType sourceType);
}
148 changes: 148 additions & 0 deletions src/main/java/com/umc/domain/perfume/service/PerfumeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PerfumeResponseDto> 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<Perfume> recentPerfumes = perfumeRepository.findTop10BySourceTypeOrderByCreatedAtDesc(sourceType);

if (recentPerfumes.isEmpty()) {
// 추천할 향수가 없으면 기본 향수들 생성
return createDefaultRecommendations(sourceType);
}

// 향수들을 DTO로 변환
List<PerfumeResponseDto> recommendations = recentPerfumes.stream()
.map(PerfumeResponseDto::from)
.toList();

log.info("향수 추천 완료 - 타입: {}, 추천 개수: {}", sourceType, recommendations.size());

return recommendations;
}

/**
* 기본 추천 향수들 생성 (추천할 향수가 없을 때) - 최대 10개
*/
private List<PerfumeResponseDto> createDefaultRecommendations(SourceType sourceType) {
List<PerfumeResponseDto> 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;
}
}