diff --git a/src/main/java/com/daytodo/domain/course/service/AiCourseCandidate.java b/src/main/java/com/daytodo/domain/course/service/AiCourseCandidate.java new file mode 100644 index 0000000..f60f0a6 --- /dev/null +++ b/src/main/java/com/daytodo/domain/course/service/AiCourseCandidate.java @@ -0,0 +1,27 @@ +package com.daytodo.domain.course.service; + +import com.daytodo.domain.place.entity.Place; +import com.daytodo.domain.place.entity.PlacePriceEstimate; + +import java.util.Optional; + +record AiCourseCandidate( + String key, String type, String externalId, String placeName, String category, String address, String roadAddress, + double latitude, double longitude, String phone, String description, Place place, Optional priceEstimate +) { + static AiCourseCandidate discovered(String key, String type, String externalId, String placeName, String category, String address, + String roadAddress, double latitude, double longitude, String phone, String description) { + return new AiCourseCandidate(key, type, externalId, placeName, category, address, roadAddress, latitude, longitude, phone, description, + null, Optional.empty()); + } + + AiCourseCandidate withPlace(Place place, Optional priceEstimate) { + return new AiCourseCandidate(key, type, externalId, placeName, category, address, roadAddress, latitude, longitude, phone, description, + place, priceEstimate); + } + + AiCourseCandidate withPriceEstimate(PlacePriceEstimate estimate) { + return new AiCourseCandidate(key, type, externalId, placeName, category, address, roadAddress, latitude, longitude, phone, description, + place, Optional.of(estimate)); + } +} diff --git a/src/main/java/com/daytodo/domain/course/service/AiPriceInferenceClient.java b/src/main/java/com/daytodo/domain/course/service/AiPriceInferenceClient.java new file mode 100644 index 0000000..1d3162d --- /dev/null +++ b/src/main/java/com/daytodo/domain/course/service/AiPriceInferenceClient.java @@ -0,0 +1,15 @@ +package com.daytodo.domain.course.service; + +import java.util.List; +import java.util.Map; + +/** OpenAI 등 가격 추론 제공자를 교체 가능하게 분리한 포트. */ +public interface AiPriceInferenceClient { + Map estimate(List places); + + record PlaceInput(String key, String courseType, String placeName, String category, String description) { + } + + record PriceEstimate(int minPrice, int maxPrice, double confidence, String reason) { + } +} diff --git a/src/main/java/com/daytodo/domain/course/service/CourseAiRecommendationPersistenceService.java b/src/main/java/com/daytodo/domain/course/service/CourseAiRecommendationPersistenceService.java new file mode 100644 index 0000000..f304c51 --- /dev/null +++ b/src/main/java/com/daytodo/domain/course/service/CourseAiRecommendationPersistenceService.java @@ -0,0 +1,70 @@ +package com.daytodo.domain.course.service; + +import com.daytodo.domain.place.entity.Place; +import com.daytodo.domain.place.entity.PlacePriceEstimate; +import com.daytodo.domain.place.repository.PlacePriceEstimateRepository; +import com.daytodo.domain.place.repository.PlaceRepository; +import com.daytodo.domain.region.entity.Region; +import com.daytodo.domain.region.exception.code.RegionErrorCode; +import com.daytodo.domain.region.repository.RegionRepository; +import com.daytodo.global.apiPayload.exception.ProjectException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +class CourseAiRecommendationPersistenceService { + private final RegionRepository regionRepository; + private final PlaceRepository placeRepository; + private final PlacePriceEstimateRepository placePriceEstimateRepository; + + @Transactional + public List resolveCandidates(Long regionId, List candidates) { + if (candidates.isEmpty()) return List.of(); + Region region = regionRepository.findById(regionId) + .orElseThrow(() -> new ProjectException(RegionErrorCode.REGION_NOT_FOUND)); + return candidates.stream().map(candidate -> { + Place place = placeRepository.findByNaverPlaceId(candidate.externalId()) + .orElseGet(() -> placeRepository.save(new Place(region, candidate.externalId(), candidate.placeName(), candidate.category(), + candidate.address(), candidate.roadAddress(), candidate.latitude(), candidate.longitude(), candidate.phone(), + candidate.description(), null))); + return candidate.withPlace(place, placePriceEstimateRepository.findByPlace(place)); + }).toList(); + } + + @Transactional + public List savePriceEstimates(List candidates, + Map inferred) { + Map uniqueMissingCandidates = new LinkedHashMap<>(); + for (AiCourseCandidate candidate : candidates) { + if (candidate.priceEstimate().isEmpty()) uniqueMissingCandidates.putIfAbsent(candidate.place().getPlaceId(), candidate); + } + + Map savedEstimates = new LinkedHashMap<>(); + for (AiCourseCandidate candidate : uniqueMissingCandidates.values()) { + PlacePriceEstimate estimate = placePriceEstimateRepository.findByPlace(candidate.place()) + .orElseGet(() -> saveEstimate(candidate, inferred.get(candidate.key()))); + if (estimate != null) savedEstimates.put(candidate.place().getPlaceId(), estimate); + } + + return candidates.stream() + .map(candidate -> candidate.priceEstimate().map(candidate::withPriceEstimate) + .orElseGet(() -> { + PlacePriceEstimate estimate = savedEstimates.get(candidate.place().getPlaceId()); + return estimate == null ? null : candidate.withPriceEstimate(estimate); + })) + .filter(java.util.Objects::nonNull) + .toList(); + } + + private PlacePriceEstimate saveEstimate(AiCourseCandidate candidate, AiPriceInferenceClient.PriceEstimate estimate) { + if (estimate == null) return null; + return placePriceEstimateRepository.save(new PlacePriceEstimate(candidate.place(), estimate.minPrice(), estimate.maxPrice(), + estimate.confidence(), estimate.reason())); + } +} diff --git a/src/main/java/com/daytodo/domain/course/service/CourseAiRecommendationService.java b/src/main/java/com/daytodo/domain/course/service/CourseAiRecommendationService.java new file mode 100644 index 0000000..27e7f01 --- /dev/null +++ b/src/main/java/com/daytodo/domain/course/service/CourseAiRecommendationService.java @@ -0,0 +1,147 @@ +package com.daytodo.domain.course.service; + +import com.daytodo.domain.course.dto.CourseRequest; +import com.daytodo.domain.course.dto.CourseResponse; +import com.daytodo.domain.place.infra.NaverLocalSearchClient; +import com.daytodo.domain.place.infra.NaverLocalSearchResponse; +import com.daytodo.domain.region.entity.Region; +import com.daytodo.domain.region.exception.code.RegionErrorCode; +import com.daytodo.domain.region.repository.RegionRepository; +import com.daytodo.global.apiPayload.exception.ProjectException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.util.HtmlUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.IntStream; + +@Service +@RequiredArgsConstructor +public class CourseAiRecommendationService { + private static final String NO_PLACES_MESSAGE = "해당 조건의 장소가 없습니다."; + private static final String SUCCESS_MESSAGE = "성공적으로 요청을 처리했습니다."; + private static final String SUCCESS_CODE = "COMMON200"; + private static final List COURSE_TYPES = List.of("식당", "카페", "놀거리"); + + private final RegionRepository regionRepository; + private final CourseAiRecommendationPersistenceService persistenceService; + private final NaverLocalSearchClient naverLocalSearchClient; + private final AiPriceInferenceClient aiPriceInferenceClient; + + public CourseResponse.AiRecommendations recommend(CourseRequest.AiRecommendation request) { + Region region = regionRepository.findById(request.regionId()) + .orElseThrow(() -> new ProjectException(RegionErrorCode.REGION_NOT_FOUND)); + + // Naver/Gemini 원격 호출은 트랜잭션 밖에서 수행한다. + List discoveredCandidates = searchCandidates(region.getRegionName()); + List candidates = persistenceService.resolveCandidates(region.getRegionId(), discoveredCandidates); + if (candidates.isEmpty()) return emptyResponse(); + Map inferred = aiPriceInferenceClient.estimate(toPriceInputs(candidates)); + List pricedCandidates = persistenceService.savePriceEstimates(candidates, inferred); + + List courses = combine( + region.getRegionName(), pricedCandidates, request.minPrice(), request.maxPrice()); + return courses.isEmpty() ? emptyResponse() : new CourseResponse.AiRecommendations(true, SUCCESS_CODE, SUCCESS_MESSAGE, courses); + } + + private CourseResponse.AiRecommendations emptyResponse() { + return new CourseResponse.AiRecommendations(true, SUCCESS_CODE, NO_PLACES_MESSAGE, List.of()); + } + + private List searchCandidates(String regionName) { + List candidates = new ArrayList<>(); + for (String type : COURSE_TYPES) { + NaverLocalSearchResponse response = naverLocalSearchClient.search(regionName + " " + type); + if (response == null || response.items() == null) continue; + IntStream.range(0, response.items().size()) + .mapToObj(index -> toCandidate(type, index, response.items().get(index))) + .flatMap(Optional::stream) + .forEach(candidates::add); + } + return candidates; + } + + private Optional toCandidate(String type, int index, NaverLocalSearchResponse.Item item) { + Double latitude = coordinate(item.mapy()); + Double longitude = coordinate(item.mapx()); + if (latitude == null || longitude == null) return Optional.empty(); + return Optional.of(AiCourseCandidate.discovered(type + "-" + index, type, externalId(item), clean(item.title()), + category(item.category()), requiredText(item.address()), emptyToNull(item.roadAddress()), latitude, longitude, + emptyToNull(item.telephone()), emptyToNull(clean(item.description())))); + } + + private List toPriceInputs(List candidates) { + return candidates.stream() + .filter(candidate -> candidate.priceEstimate().isEmpty()) + .collect(java.util.stream.Collectors.toMap(candidate -> candidate.place().getPlaceId(), candidate -> candidate, + (left, right) -> left, java.util.LinkedHashMap::new)) + .values().stream() + .map(candidate -> new AiPriceInferenceClient.PlaceInput(candidate.key(), candidate.type(), candidate.place().getPlaceName(), + candidate.place().getCategory(), candidate.description())) + .toList(); + } + + private List combine( + String regionName, List candidates, int minBudget, int maxBudget) { + List restaurants = byType(candidates, "식당"); + List cafes = byType(candidates, "카페"); + List activities = byType(candidates, "놀거리"); + List> valid = new ArrayList<>(); + for (AiCourseCandidate restaurant : restaurants) for (AiCourseCandidate cafe : cafes) for (AiCourseCandidate activity : activities) { + List course = List.of(restaurant, cafe, activity); + int totalMin = course.stream().mapToInt(candidate -> candidate.priceEstimate().orElseThrow().getMinPrice()).sum(); + int totalMax = course.stream().mapToInt(candidate -> candidate.priceEstimate().orElseThrow().getMaxPrice()).sum(); + if (totalMin >= minBudget && totalMax <= maxBudget) valid.add(course); + } + List> selected = valid.stream() + .sorted(Comparator.comparingInt(course -> course.stream().mapToInt(c -> c.priceEstimate().orElseThrow().getMaxPrice()).sum())) + .limit(2).toList(); + return IntStream.range(0, selected.size()) + .mapToObj(index -> toCourse(regionName, index + 1, selected.get(index))) + .toList(); + } + + private List byType(List candidates, String type) { + return candidates.stream().filter(candidate -> candidate.type().equals(type)).toList(); + } + + private CourseResponse.AiRecommendationCourse toCourse(String regionName, int courseNumber, List candidates) { + int totalMin = candidates.stream().mapToInt(candidate -> candidate.priceEstimate().orElseThrow().getMinPrice()).sum(); + int totalMax = candidates.stream().mapToInt(candidate -> candidate.priceEstimate().orElseThrow().getMaxPrice()).sum(); + List places = IntStream.range(0, candidates.size()) + .mapToObj(index -> toPlace(index + 1, candidates.get(index))).toList(); + return new CourseResponse.AiRecommendationCourse(regionName + " AI 추천 코스 " + courseNumber, totalMin, totalMax, places); + } + + private CourseResponse.AiRecommendationPlace toPlace(int order, AiCourseCandidate candidate) { + var place = candidate.place(); + var estimate = candidate.priceEstimate().orElseThrow(); + return new CourseResponse.AiRecommendationPlace(order, place.getPlaceId(), place.getNaverPlaceId(), place.getPlaceName(), + place.getCategory(), place.getAddress(), place.getRoadAddress(), place.getLatitude(), place.getLongitude(), + place.getDescription(), place.getImageUrl(), estimate.getMinPrice(), estimate.getMaxPrice(), estimate.getConfidence(), estimate.getReason()); + } + + private String externalId(NaverLocalSearchResponse.Item item) { + if (item.link() != null && !item.link().isBlank()) return item.link(); + return sha256(clean(item.title()) + "|" + requiredText(item.address()) + "|" + emptyToNull(item.roadAddress())); + } + private String sha256(String value) { + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private String clean(String value) { return value == null ? "" : HtmlUtils.htmlUnescape(value.replaceAll("<[^>]*>", "")); } + private String category(String value) { return value == null || value.isBlank() ? "기타" : clean(value).replace(">", " > "); } + private String requiredText(String value) { return value == null ? "" : value; } + private String emptyToNull(String value) { return value == null || value.isBlank() ? null : value; } + private Double coordinate(String value) { + try { return value == null || value.isBlank() ? null : Double.parseDouble(value) / 10_000_000; } + catch (NumberFormatException exception) { return null; } + } +} diff --git a/src/main/java/com/daytodo/domain/course/service/GeminiPriceInferenceClient.java b/src/main/java/com/daytodo/domain/course/service/GeminiPriceInferenceClient.java new file mode 100644 index 0000000..7d4c4d4 --- /dev/null +++ b/src/main/java/com/daytodo/domain/course/service/GeminiPriceInferenceClient.java @@ -0,0 +1,128 @@ +package com.daytodo.domain.course.service; + +import com.daytodo.domain.course.exception.code.CourseErrorCode; +import com.daytodo.global.apiPayload.exception.ProjectException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.time.Duration; + +/** Gemini의 구조화된 출력으로 아직 가격 캐시가 없는 장소들의 1인 가격을 한 번에 추론한다. */ +@Slf4j +@Component +public class GeminiPriceInferenceClient implements AiPriceInferenceClient { + private static final String GEMINI_BASE_URL = "https://generativelanguage.googleapis.com"; + + private final RestClient restClient; + private final ObjectMapper objectMapper; + private final String model; + + public GeminiPriceInferenceClient( + ObjectMapper objectMapper, + @Value("${GEMINI_API_KEY:}") String apiKey, + @Value("${GEMINI_MODEL:gemini-3.1-flash-lite}") String model + ) { + this.objectMapper = objectMapper; + this.model = model; + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(5)); + requestFactory.setReadTimeout(Duration.ofSeconds(30)); + this.restClient = RestClient.builder() + .baseUrl(GEMINI_BASE_URL) + .defaultHeader("x-goog-api-key", apiKey) + .requestFactory(requestFactory) + .build(); + } + + @Override + public Map estimate(List places) { + if (places.isEmpty()) return Map.of(); + try { + String response = restClient.post() + .uri("/v1beta/models/{model}:generateContent", model) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of( + "contents", List.of(Map.of("parts", List.of(Map.of("text", prompt(places))))), + "generationConfig", Map.of( + "responseMimeType", "application/json", + "responseSchema", responseSchema() + ) + )) + .retrieve() + .body(String.class); + return parse(response); + } catch (RestClientException | IllegalArgumentException exception) { + log.error("Gemini 가격 추론 호출에 실패했습니다.", exception); + throw new ProjectException(CourseErrorCode.COURSE_RECOMMENDATION_FAILED); + } + } + + private String prompt(List places) { + String candidates = places.stream() + .map(place -> "key=" + place.key() + ", type=" + place.courseType() + + ", name=" + place.placeName() + ", category=" + place.category() + + ", description=" + nullToEmpty(place.description())) + .reduce((left, right) -> left + "\n" + right) + .orElseThrow(); + return "한국의 지역 장소를 기준으로 1인 평균 이용 금액을 원화로 추론하세요. " + + "가격은 일반적인 메뉴·이용권 기준이어야 하며, reason은 한국어로 작성하세요.\nCandidates:\n" + candidates; + } + + private Map responseSchema() { + return Map.of( + "type", "OBJECT", + "properties", Map.of("estimates", Map.of( + "type", "ARRAY", + "items", Map.of( + "type", "OBJECT", + "properties", Map.of( + "key", Map.of("type", "STRING"), + "minPrice", Map.of("type", "INTEGER", "minimum", 0), + "maxPrice", Map.of("type", "INTEGER", "minimum", 0), + "confidence", Map.of("type", "NUMBER", "minimum", 0, "maximum", 1), + "reason", Map.of("type", "STRING") + ), + "required", List.of("key", "minPrice", "maxPrice", "confidence", "reason") + ) + )), + "required", List.of("estimates") + ); + } + + private Map parse(String response) { + try { + JsonNode root = objectMapper.readTree(response); + JsonNode estimates = objectMapper.readTree(root.path("candidates").path(0) + .path("content").path("parts").path(0).path("text").asText()).path("estimates"); + Map result = new LinkedHashMap<>(); + for (JsonNode estimate : estimates) { + int minPrice = estimate.path("minPrice").asInt(-1); + int maxPrice = estimate.path("maxPrice").asInt(-1); + double confidence = estimate.path("confidence").asDouble(-1); + String key = estimate.path("key").asText(); + if (!key.isBlank() && minPrice >= 0 && maxPrice >= minPrice && confidence >= 0 && confidence <= 1) { + result.put(key, new PriceEstimate(minPrice, maxPrice, confidence, estimate.path("reason").asText(null))); + } + } + if (result.isEmpty()) throw new IllegalArgumentException("Gemini price response is empty"); + return result; + } catch (Exception exception) { + log.error("Gemini 가격 추론 응답을 해석하지 못했습니다.", exception); + throw new ProjectException(CourseErrorCode.COURSE_RECOMMENDATION_FAILED); + } + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/daytodo/domain/place/repository/PlacePriceEstimateRepository.java b/src/main/java/com/daytodo/domain/place/repository/PlacePriceEstimateRepository.java new file mode 100644 index 0000000..e90e024 --- /dev/null +++ b/src/main/java/com/daytodo/domain/place/repository/PlacePriceEstimateRepository.java @@ -0,0 +1,11 @@ +package com.daytodo.domain.place.repository; + +import com.daytodo.domain.place.entity.Place; +import com.daytodo.domain.place.entity.PlacePriceEstimate; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PlacePriceEstimateRepository extends JpaRepository { + Optional findByPlace(Place place); +} diff --git a/src/test/java/com/daytodo/domain/course/service/CourseAiRecommendationPersistenceServiceTest.java b/src/test/java/com/daytodo/domain/course/service/CourseAiRecommendationPersistenceServiceTest.java new file mode 100644 index 0000000..1dc37b8 --- /dev/null +++ b/src/test/java/com/daytodo/domain/course/service/CourseAiRecommendationPersistenceServiceTest.java @@ -0,0 +1,57 @@ +package com.daytodo.domain.course.service; + +import com.daytodo.domain.place.entity.Place; +import com.daytodo.domain.place.entity.PlacePriceEstimate; +import com.daytodo.domain.place.repository.PlacePriceEstimateRepository; +import com.daytodo.domain.place.repository.PlaceRepository; +import com.daytodo.domain.region.repository.RegionRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CourseAiRecommendationPersistenceServiceTest { + @Mock RegionRepository regionRepository; + @Mock PlaceRepository placeRepository; + @Mock PlacePriceEstimateRepository placePriceEstimateRepository; + + @Test + void savesOnlyOneEstimateWhenCandidatesReferToSamePlace() { + CourseAiRecommendationPersistenceService service = new CourseAiRecommendationPersistenceService( + regionRepository, placeRepository, placePriceEstimateRepository); + Place place = new Place(null, "place-key", "중복 장소", "카페", "서울", null, 37.5, 126.9, null, null, null); + ReflectionTestUtils.setField(place, "placeId", 1L); + AiCourseCandidate first = candidate("카페-0", place); + AiCourseCandidate second = candidate("놀거리-0", place); + + when(placePriceEstimateRepository.findByPlace(place)).thenReturn(Optional.empty()); + when(placePriceEstimateRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + List result = service.savePriceEstimates(List.of(first, second), Map.of( + "카페-0", new AiPriceInferenceClient.PriceEstimate(5_000, 8_000, .8, "가격"), + "놀거리-0", new AiPriceInferenceClient.PriceEstimate(7_000, 10_000, .8, "가격") + )); + + assertThat(result).hasSize(2); + assertThat(result.get(0).priceEstimate()).isPresent(); + assertThat(result.get(1).priceEstimate()).isPresent(); + verify(placePriceEstimateRepository, times(1)).save(any(PlacePriceEstimate.class)); + } + + private AiCourseCandidate candidate(String key, Place place) { + return AiCourseCandidate.discovered(key, "카페", "place-key", "중복 장소", "카페", "서울", null, + 37.5, 126.9, null, null).withPlace(place, Optional.empty()); + } +} diff --git a/src/test/java/com/daytodo/domain/course/service/CourseAiRecommendationServiceTest.java b/src/test/java/com/daytodo/domain/course/service/CourseAiRecommendationServiceTest.java new file mode 100644 index 0000000..f3a48fa --- /dev/null +++ b/src/test/java/com/daytodo/domain/course/service/CourseAiRecommendationServiceTest.java @@ -0,0 +1,116 @@ +package com.daytodo.domain.course.service; + +import com.daytodo.domain.course.dto.CourseRequest; +import com.daytodo.domain.course.dto.CourseResponse; +import com.daytodo.domain.place.entity.Place; +import com.daytodo.domain.place.entity.PlacePriceEstimate; +import com.daytodo.domain.place.infra.NaverLocalSearchClient; +import com.daytodo.domain.place.infra.NaverLocalSearchResponse; +import com.daytodo.domain.region.entity.Region; +import com.daytodo.domain.region.enums.RegionLevel; +import com.daytodo.domain.region.repository.RegionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CourseAiRecommendationServiceTest { + @Mock RegionRepository regionRepository; + @Mock CourseAiRecommendationPersistenceService persistenceService; + @Mock NaverLocalSearchClient naverLocalSearchClient; + @Mock AiPriceInferenceClient aiPriceInferenceClient; + + private CourseAiRecommendationService service; + private Region hongdae; + + @BeforeEach + void setUp() { + service = new CourseAiRecommendationService(regionRepository, persistenceService, + naverLocalSearchClient, aiPriceInferenceClient); + hongdae = new Region(null, "홍대", RegionLevel.SIGUNGU); + ReflectionTestUtils.setField(hongdae, "regionId", 1L); + when(regionRepository.findById(1L)).thenReturn(Optional.of(hongdae)); + } + + @Test + void returnsRestaurantCafeAndActivityCourseWithinBudget() { + Place restaurant = place(1L, "식당"); + Place cafe = place(2L, "카페"); + Place activity = place(3L, "놀거리"); + when(naverLocalSearchClient.search("홍대 식당")).thenReturn(response("식당", "restaurant-link")); + when(naverLocalSearchClient.search("홍대 카페")).thenReturn(response("카페", "cafe-link")); + when(naverLocalSearchClient.search("홍대 놀거리")).thenReturn(response("놀거리", "activity-link")); + when(persistenceService.resolveCandidates(any(), any())).thenAnswer(invocation -> { + List discovered = invocation.getArgument(1); + return discovered.stream().map(candidate -> switch (candidate.type()) { + case "식당" -> candidate.withPlace(restaurant, Optional.empty()); + case "카페" -> candidate.withPlace(cafe, Optional.empty()); + default -> candidate.withPlace(activity, Optional.empty()); + }).toList(); + }); + when(aiPriceInferenceClient.estimate(any())).thenReturn(Map.of( + "식당-0", new AiPriceInferenceClient.PriceEstimate(12_000, 15_000, .8, "식사 가격"), + "카페-0", new AiPriceInferenceClient.PriceEstimate(5_000, 7_000, .8, "음료 가격"), + "놀거리-0", new AiPriceInferenceClient.PriceEstimate(8_000, 9_000, .8, "이용 가격") + )); + when(persistenceService.savePriceEstimates(any(), any())).thenAnswer(invocation -> { + List candidates = invocation.getArgument(0); + Map estimates = invocation.getArgument(1); + return candidates.stream().map(candidate -> { + AiPriceInferenceClient.PriceEstimate estimate = estimates.get(candidate.key()); + return candidate.withPriceEstimate(new PlacePriceEstimate(candidate.place(), estimate.minPrice(), estimate.maxPrice(), + estimate.confidence(), estimate.reason())); + }).toList(); + }); + + CourseResponse.AiRecommendations result = service.recommend( + new CourseRequest.AiRecommendation(1L, 25_000, 35_000)); + + assertThat(result.success()).isTrue(); + assertThat(result.code()).isEqualTo("COMMON200"); + assertThat(result.result()).hasSize(1); + assertThat(result.result().get(0).estimatedTotalMinPrice()).isEqualTo(25_000); + assertThat(result.result().get(0).estimatedTotalMaxPrice()).isEqualTo(31_000); + assertThat(result.result().get(0).places()).extracting(CourseResponse.AiRecommendationPlace::recommendationOrder) + .containsExactly(1, 2, 3); + } + + @Test + void returnsEmptyListWhenNoCombinationMatchesBudget() { + when(naverLocalSearchClient.search(anyString())).thenReturn(new NaverLocalSearchResponse(0, 0, 0, List.of())); + + CourseResponse.AiRecommendations result = service.recommend( + new CourseRequest.AiRecommendation(1L, 10_000, 30_000)); + + assertThat(result.message()).isEqualTo("해당 조건의 장소가 없습니다."); + assertThat(result.success()).isTrue(); + assertThat(result.code()).isEqualTo("COMMON200"); + assertThat(result.result()).isEmpty(); + } + + private Place place(Long id, String name) { + Place place = new Place(hongdae, name + "-id", name, name, "서울 마포구", null, + 37.5, 126.9, null, null, null); + ReflectionTestUtils.setField(place, "placeId", id); + return place; + } + + private NaverLocalSearchResponse response(String name, String link) { + return new NaverLocalSearchResponse(1, 1, 1, List.of( + new NaverLocalSearchResponse.Item(name, link, name, "설명", null, + "서울 마포구", "서울 마포구", "1269000000", "375000000") + )); + } +} diff --git a/src/test/java/com/daytodo/domain/place/entity/PlacePriceEstimateTest.java b/src/test/java/com/daytodo/domain/place/entity/PlacePriceEstimateTest.java new file mode 100644 index 0000000..cc5feee --- /dev/null +++ b/src/test/java/com/daytodo/domain/place/entity/PlacePriceEstimateTest.java @@ -0,0 +1,15 @@ +package com.daytodo.domain.place.entity; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PlacePriceEstimateTest { + + @Test + void rejectsPriceRangeWithMaxPriceLowerThanMinPrice() { + assertThatThrownBy(() -> new PlacePriceEstimate(null, 10_000, 5_000, 0.8, "가격 추론")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("최대 가격은 최소 가격보다 작을 수 없습니다."); + } +}