init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
|
||||
// AI and Location Services
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ktds.hi.recommend;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
/**
|
||||
* 추천 서비스 메인 애플리케이션 클래스
|
||||
* 가게 추천, 취향 분석 기능을 제공
|
||||
*
|
||||
* @author 하이오더 개발팀
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = {"com.ktds.hi.recommend", "com.ktds.hi.common"})
|
||||
@EnableJpaAuditing
|
||||
public class RecommendServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RecommendServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 위치 도메인 클래스
|
||||
* 위치 정보를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Location {
|
||||
|
||||
private Long id;
|
||||
private String address;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
private String city;
|
||||
private String district;
|
||||
private String country;
|
||||
|
||||
/**
|
||||
* 좌표 업데이트
|
||||
*/
|
||||
public Location updateCoordinates(Double newLatitude, Double newLongitude) {
|
||||
return Location.builder()
|
||||
.id(this.id)
|
||||
.address(this.address)
|
||||
.latitude(newLatitude)
|
||||
.longitude(newLongitude)
|
||||
.city(this.city)
|
||||
.district(this.district)
|
||||
.country(this.country)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 추천 히스토리 도메인 클래스
|
||||
* 사용자의 추천 기록을 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RecommendHistory {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private List<Long> recommendedStoreIds;
|
||||
private RecommendType recommendType;
|
||||
private String criteria;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 추천 기준 업데이트
|
||||
*/
|
||||
public RecommendHistory updateCriteria(String newCriteria) {
|
||||
return RecommendHistory.builder()
|
||||
.id(this.id)
|
||||
.memberId(this.memberId)
|
||||
.recommendedStoreIds(this.recommendedStoreIds)
|
||||
.recommendType(this.recommendType)
|
||||
.criteria(newCriteria)
|
||||
.createdAt(this.createdAt)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 추천 매장 도메인 클래스
|
||||
* 추천된 매장 정보를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RecommendStore {
|
||||
|
||||
private Long storeId;
|
||||
private String storeName;
|
||||
private String address;
|
||||
private String category;
|
||||
private List<String> tags;
|
||||
private Double rating;
|
||||
private Integer reviewCount;
|
||||
private Double distance;
|
||||
private Double recommendScore;
|
||||
private RecommendType recommendType;
|
||||
private String recommendReason;
|
||||
|
||||
/**
|
||||
* 추천 점수 업데이트
|
||||
*/
|
||||
public RecommendStore updateRecommendScore(Double newScore) {
|
||||
return RecommendStore.builder()
|
||||
.storeId(this.storeId)
|
||||
.storeName(this.storeName)
|
||||
.address(this.address)
|
||||
.category(this.category)
|
||||
.tags(this.tags)
|
||||
.rating(this.rating)
|
||||
.reviewCount(this.reviewCount)
|
||||
.distance(this.distance)
|
||||
.recommendScore(newScore)
|
||||
.recommendType(this.recommendType)
|
||||
.recommendReason(this.recommendReason)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 이유 업데이트
|
||||
*/
|
||||
public RecommendStore updateRecommendReason(String newReason) {
|
||||
return RecommendStore.builder()
|
||||
.storeId(this.storeId)
|
||||
.storeName(this.storeName)
|
||||
.address(this.address)
|
||||
.category(this.category)
|
||||
.tags(this.tags)
|
||||
.rating(this.rating)
|
||||
.reviewCount(this.reviewCount)
|
||||
.distance(this.distance)
|
||||
.recommendScore(this.recommendScore)
|
||||
.recommendType(this.recommendType)
|
||||
.recommendReason(newReason)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
/**
|
||||
* 추천 유형 열거형
|
||||
* 추천의 종류를 정의
|
||||
*/
|
||||
public enum RecommendType {
|
||||
TASTE_BASED("취향 기반"),
|
||||
LOCATION_BASED("위치 기반"),
|
||||
POPULARITY_BASED("인기 기반"),
|
||||
COLLABORATIVE_FILTERING("협업 필터링"),
|
||||
AI_RECOMMENDATION("AI 추천"),
|
||||
SIMILAR_USER("유사 사용자 기반");
|
||||
|
||||
private final String description;
|
||||
|
||||
RecommendType(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
/**
|
||||
* 취향 카테고리 열거형
|
||||
* 음식 카테고리를 정의
|
||||
*/
|
||||
public enum TasteCategory {
|
||||
KOREAN("한식"),
|
||||
CHINESE("중식"),
|
||||
JAPANESE("일식"),
|
||||
WESTERN("양식"),
|
||||
FAST_FOOD("패스트푸드"),
|
||||
CAFE("카페"),
|
||||
DESSERT("디저트"),
|
||||
CHICKEN("치킨"),
|
||||
PIZZA("피자"),
|
||||
ASIAN("아시안"),
|
||||
VEGETARIAN("채식"),
|
||||
SEAFOOD("해산물");
|
||||
|
||||
private final String description;
|
||||
|
||||
TasteCategory(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 취향 프로필 도메인 클래스
|
||||
* 사용자의 취향 분석 결과를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TasteProfile {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private List<TasteCategory> preferredCategories;
|
||||
private Map<String, Double> categoryScores;
|
||||
private List<String> preferredTags;
|
||||
private Map<String, Object> behaviorPatterns;
|
||||
private Double pricePreference;
|
||||
private Double distancePreference;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 취향 프로필 업데이트
|
||||
*/
|
||||
public TasteProfile updateProfile(List<TasteCategory> categories, Map<String, Double> scores,
|
||||
List<String> tags, Map<String, Object> patterns,
|
||||
Double pricePreference, Double distancePreference) {
|
||||
return TasteProfile.builder()
|
||||
.id(this.id)
|
||||
.memberId(this.memberId)
|
||||
.preferredCategories(categories)
|
||||
.categoryScores(scores)
|
||||
.preferredTags(tags)
|
||||
.behaviorPatterns(patterns)
|
||||
.pricePreference(pricePreference)
|
||||
.distancePreference(distancePreference)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.ktds.hi.recommend.biz.service;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.in.StoreRecommendUseCase;
|
||||
import com.ktds.hi.recommend.biz.usecase.out.*;
|
||||
import com.ktds.hi.recommend.biz.domain.*;
|
||||
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
|
||||
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 매장 추천 인터랙터 클래스
|
||||
* 사용자 취향 기반 매장 추천 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class StoreRecommendInteractor implements StoreRecommendUseCase {
|
||||
|
||||
private final RecommendRepository recommendRepository;
|
||||
private final AiRecommendRepository aiRecommendRepository;
|
||||
private final LocationRepository locationRepository;
|
||||
private final UserPreferenceRepository userPreferenceRepository;
|
||||
|
||||
@Override
|
||||
public List<RecommendStoreResponse> recommendStores(Long memberId, RecommendStoreRequest request) {
|
||||
// 사용자 취향 프로필 조회
|
||||
TasteProfile tasteProfile = userPreferenceRepository.getMemberPreferences(memberId)
|
||||
.orElseThrow(() -> new BusinessException("사용자 취향 정보를 찾을 수 없습니다. 취향 등록을 먼저 해주세요."));
|
||||
|
||||
// AI 기반 추천
|
||||
Map<String, Object> preferences = Map.of(
|
||||
"categories", tasteProfile.getPreferredCategories(),
|
||||
"tags", tasteProfile.getPreferredTags(),
|
||||
"pricePreference", tasteProfile.getPricePreference(),
|
||||
"distancePreference", tasteProfile.getDistancePreference(),
|
||||
"latitude", request.getLatitude(),
|
||||
"longitude", request.getLongitude()
|
||||
);
|
||||
|
||||
List<RecommendStore> aiRecommendStores = aiRecommendRepository.recommendStoresByAI(memberId, preferences);
|
||||
|
||||
// 위치 기반 추천 결합
|
||||
List<RecommendStore> locationStores = locationRepository.findStoresWithinRadius(
|
||||
request.getLatitude(), request.getLongitude(), request.getRadius());
|
||||
|
||||
// 추천 결과 통합 및 점수 계산
|
||||
List<RecommendStore> combinedStores = combineRecommendations(aiRecommendStores, locationStores, tasteProfile);
|
||||
|
||||
// 추천 히스토리 저장
|
||||
RecommendHistory history = RecommendHistory.builder()
|
||||
.memberId(memberId)
|
||||
.recommendedStoreIds(combinedStores.stream().map(RecommendStore::getStoreId).collect(Collectors.toList()))
|
||||
.recommendType(RecommendType.TASTE_BASED)
|
||||
.criteria("취향 + AI + 위치 기반 통합 추천")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
recommendRepository.saveRecommendHistory(history);
|
||||
|
||||
log.info("매장 추천 완료: memberId={}, 추천 매장 수={}", memberId, combinedStores.size());
|
||||
|
||||
return combinedStores.stream()
|
||||
.map(this::toRecommendStoreResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius) {
|
||||
List<RecommendStore> stores = locationRepository.findStoresWithinRadius(latitude, longitude, radius);
|
||||
|
||||
return stores.stream()
|
||||
.map(store -> store.updateRecommendReason("위치 기반 추천"))
|
||||
.map(this::toRecommendStoreResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecommendStoreResponse> recommendPopularStores(String category, Integer limit) {
|
||||
// Mock 구현 - 실제로는 인기도 기반 쿼리 필요
|
||||
List<RecommendStore> popularStores = List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(1L)
|
||||
.storeName("인기 매장 1")
|
||||
.address("서울시 강남구")
|
||||
.category(category)
|
||||
.rating(4.5)
|
||||
.reviewCount(100)
|
||||
.recommendScore(95.0)
|
||||
.recommendType(RecommendType.POPULARITY_BASED)
|
||||
.recommendReason("높은 평점과 많은 리뷰")
|
||||
.build()
|
||||
);
|
||||
|
||||
return popularStores.stream()
|
||||
.limit(limit != null ? limit : 10)
|
||||
.map(this::toRecommendStoreResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 결과 통합 및 점수 계산
|
||||
*/
|
||||
private List<RecommendStore> combineRecommendations(List<RecommendStore> aiStores,
|
||||
List<RecommendStore> locationStores,
|
||||
TasteProfile profile) {
|
||||
// AI 추천과 위치 기반 추천을 통합하여 최종 점수 계산
|
||||
// 실제로는 더 복잡한 로직이 필요
|
||||
|
||||
return aiStores.stream()
|
||||
.map(store -> store.updateRecommendScore(
|
||||
calculateFinalScore(store, profile)
|
||||
))
|
||||
.sorted((s1, s2) -> Double.compare(s2.getRecommendScore(), s1.getRecommendScore()))
|
||||
.limit(20)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 최종 추천 점수 계산
|
||||
*/
|
||||
private Double calculateFinalScore(RecommendStore store, TasteProfile profile) {
|
||||
double baseScore = store.getRecommendScore() != null ? store.getRecommendScore() : 0.0;
|
||||
double ratingScore = store.getRating() != null ? store.getRating() * 10 : 0.0;
|
||||
double reviewScore = store.getReviewCount() != null ? Math.min(store.getReviewCount() * 0.1, 10) : 0.0;
|
||||
double distanceScore = store.getDistance() != null ? Math.max(0, 10 - store.getDistance() / 1000) : 0.0;
|
||||
|
||||
return (baseScore * 0.4) + (ratingScore * 0.3) + (reviewScore * 0.2) + (distanceScore * 0.1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인을 응답 DTO로 변환
|
||||
*/
|
||||
private RecommendStoreResponse toRecommendStoreResponse(RecommendStore store) {
|
||||
return RecommendStoreResponse.builder()
|
||||
.storeId(store.getStoreId())
|
||||
.storeName(store.getStoreName())
|
||||
.address(store.getAddress())
|
||||
.category(store.getCategory())
|
||||
.tags(store.getTags())
|
||||
.rating(store.getRating())
|
||||
.reviewCount(store.getReviewCount())
|
||||
.distance(store.getDistance())
|
||||
.recommendScore(store.getRecommendScore())
|
||||
.recommendReason(store.getRecommendReason())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.ktds.hi.recommend.biz.service;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.in.TasteAnalysisUseCase;
|
||||
import com.ktds.hi.recommend.biz.usecase.out.UserPreferenceRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteCategory;
|
||||
import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 취향 분석 인터랙터 클래스
|
||||
* 사용자 취향 분석 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class TasteAnalysisInteractor implements TasteAnalysisUseCase {
|
||||
|
||||
private final UserPreferenceRepository userPreferenceRepository;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public TasteAnalysisResponse analyzeMemberTaste(Long memberId) {
|
||||
TasteProfile profile = userPreferenceRepository.getMemberPreferences(memberId)
|
||||
.orElseThrow(() -> new BusinessException("사용자 취향 정보를 찾을 수 없습니다"));
|
||||
|
||||
// 취향 분석 결과 생성
|
||||
List<String> preferredCategories = profile.getPreferredCategories()
|
||||
.stream()
|
||||
.map(TasteCategory::getDescription)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Double> categoryScores = profile.getCategoryScores();
|
||||
String topCategory = categoryScores.entrySet()
|
||||
.stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse("정보 없음");
|
||||
|
||||
return TasteAnalysisResponse.builder()
|
||||
.memberId(memberId)
|
||||
.preferredCategories(preferredCategories)
|
||||
.topCategory(topCategory)
|
||||
.categoryScores(categoryScores)
|
||||
.preferredTags(profile.getPreferredTags())
|
||||
.pricePreference(profile.getPricePreference())
|
||||
.distancePreference(profile.getDistancePreference())
|
||||
.analysisDate(profile.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateTasteProfile(Long memberId) {
|
||||
log.info("취향 프로필 업데이트 시작: memberId={}", memberId);
|
||||
|
||||
try {
|
||||
// 리뷰 기반 취향 분석
|
||||
Map<String, Object> analysisData = userPreferenceRepository.analyzePreferencesFromReviews(memberId);
|
||||
|
||||
// 취향 프로필 업데이트
|
||||
TasteProfile updatedProfile = userPreferenceRepository.updateTasteProfile(memberId, analysisData);
|
||||
|
||||
log.info("취향 프로필 업데이트 완료: memberId={}, profileId={}", memberId, updatedProfile.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("취향 프로필 업데이트 실패: memberId={}, error={}", memberId, e.getMessage(), e);
|
||||
throw new BusinessException("취향 프로필 업데이트 중 오류가 발생했습니다: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.in;
|
||||
|
||||
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
|
||||
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 추천 유스케이스 인터페이스
|
||||
* 사용자 취향 기반 매장 추천 기능을 정의
|
||||
*/
|
||||
public interface StoreRecommendUseCase {
|
||||
|
||||
/**
|
||||
* 사용자 취향 기반 매장 추천
|
||||
*/
|
||||
List<RecommendStoreResponse> recommendStores(Long memberId, RecommendStoreRequest request);
|
||||
|
||||
/**
|
||||
* 위치 기반 매장 추천
|
||||
*/
|
||||
List<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius);
|
||||
|
||||
/**
|
||||
* 인기 매장 추천
|
||||
*/
|
||||
List<RecommendStoreResponse> recommendPopularStores(String category, Integer limit);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.in;
|
||||
|
||||
import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse;
|
||||
|
||||
/**
|
||||
* 취향 분석 유스케이스 인터페이스
|
||||
* 사용자 취향 분석 기능을 정의
|
||||
*/
|
||||
public interface TasteAnalysisUseCase {
|
||||
|
||||
/**
|
||||
* 사용자 취향 분석
|
||||
*/
|
||||
TasteAnalysisResponse analyzeMemberTaste(Long memberId);
|
||||
|
||||
/**
|
||||
* 취향 프로필 업데이트
|
||||
*/
|
||||
void updateTasteProfile(Long memberId);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendStore;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 추천 리포지토리 인터페이스
|
||||
* AI 기반 추천 기능을 정의
|
||||
*/
|
||||
public interface AiRecommendRepository {
|
||||
|
||||
/**
|
||||
* AI 기반 매장 추천
|
||||
*/
|
||||
List<RecommendStore> recommendStoresByAI(Long memberId, Map<String, Object> preferences);
|
||||
|
||||
/**
|
||||
* 유사 사용자 기반 추천
|
||||
*/
|
||||
List<RecommendStore> recommendStoresBySimilarUsers(Long memberId);
|
||||
|
||||
/**
|
||||
* 협업 필터링 추천
|
||||
*/
|
||||
List<RecommendStore> recommendStoresByCollaborativeFiltering(Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.recommend.biz.domain.Location;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendStore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 위치 기반 서비스 리포지토리 인터페이스
|
||||
* 위치 정보 처리 기능을 정의
|
||||
*/
|
||||
public interface LocationRepository {
|
||||
|
||||
/**
|
||||
* 위치 정보 저장
|
||||
*/
|
||||
Location saveLocation(Location location);
|
||||
|
||||
/**
|
||||
* 반경 내 매장 조회
|
||||
*/
|
||||
List<RecommendStore> findStoresWithinRadius(Double latitude, Double longitude, Integer radius);
|
||||
|
||||
/**
|
||||
* 거리 계산
|
||||
*/
|
||||
Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2);
|
||||
|
||||
/**
|
||||
* 주소를 좌표로 변환
|
||||
*/
|
||||
Location geocodeAddress(String address);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendHistory;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 추천 리포지토리 인터페이스
|
||||
* 추천 관련 데이터 영속성 기능을 정의
|
||||
*/
|
||||
public interface RecommendRepository {
|
||||
|
||||
/**
|
||||
* 추천 히스토리 저장
|
||||
*/
|
||||
RecommendHistory saveRecommendHistory(RecommendHistory history);
|
||||
|
||||
/**
|
||||
* 회원 ID로 추천 히스토리 조회
|
||||
*/
|
||||
List<RecommendHistory> findRecommendHistoriesByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 취향 프로필 저장
|
||||
*/
|
||||
TasteProfile saveTasteProfile(TasteProfile profile);
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 프로필 조회
|
||||
*/
|
||||
Optional<TasteProfile> findTasteProfileByMemberId(Long memberId);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 사용자 선호도 리포지토리 인터페이스
|
||||
* 사용자 취향 데이터 처리 기능을 정의
|
||||
*/
|
||||
public interface UserPreferenceRepository {
|
||||
|
||||
/**
|
||||
* 회원 취향 정보 조회
|
||||
*/
|
||||
Optional<TasteProfile> getMemberPreferences(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원의 리뷰 기반 취향 분석
|
||||
*/
|
||||
Map<String, Object> analyzePreferencesFromReviews(Long memberId);
|
||||
|
||||
/**
|
||||
* 유사한 취향의 사용자 조회
|
||||
*/
|
||||
List<Long> findSimilarTasteMembers(Long memberId);
|
||||
|
||||
/**
|
||||
* 취향 프로필 업데이트
|
||||
*/
|
||||
TasteProfile updateTasteProfile(Long memberId, Map<String, Object> analysisData);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ktds.hi.recommend.infra.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/**
|
||||
* 추천 서비스 설정 클래스
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaRepositories(basePackages = "com.ktds.hi.recommend.infra.gateway.repository")
|
||||
public class RecommendConfig {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ktds.hi.recommend.infra.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Swagger 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 추천 서비스 API")
|
||||
.description("사용자 취향 기반 매장 추천 및 취향 분석 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.ktds.hi.recommend.infra.controller;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.in.StoreRecommendUseCase;
|
||||
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
|
||||
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 추천 컨트롤러 클래스
|
||||
* 매장 추천 관련 API를 제공
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/recommend")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "매장 추천 API", description = "사용자 취향 기반 매장 추천 관련 API")
|
||||
public class StoreRecommendController {
|
||||
|
||||
private final StoreRecommendUseCase storeRecommendUseCase;
|
||||
|
||||
/**
|
||||
* 사용자 취향 기반 매장 추천 API
|
||||
*/
|
||||
@PostMapping("/stores")
|
||||
@Operation(summary = "매장 추천", description = "사용자 취향과 위치를 기반으로 매장을 추천합니다.")
|
||||
public ResponseEntity<List<RecommendStoreResponse>> recommendStores(Authentication authentication,
|
||||
@Valid @RequestBody RecommendStoreRequest request) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
List<RecommendStoreResponse> recommendations = storeRecommendUseCase.recommendStores(memberId, request);
|
||||
return ResponseEntity.ok(recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 기반 매장 추천 API
|
||||
*/
|
||||
@GetMapping("/stores/nearby")
|
||||
@Operation(summary = "주변 매장 추천", description = "현재 위치 기반으로 주변 매장을 추천합니다.")
|
||||
public ResponseEntity<List<RecommendStoreResponse>> recommendNearbyStores(
|
||||
@RequestParam Double latitude,
|
||||
@RequestParam Double longitude,
|
||||
@RequestParam(defaultValue = "5000") Integer radius) {
|
||||
|
||||
List<RecommendStoreResponse> recommendations = storeRecommendUseCase
|
||||
.recommendStoresByLocation(latitude, longitude, radius);
|
||||
return ResponseEntity.ok(recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기 매장 추천 API
|
||||
*/
|
||||
@GetMapping("/stores/popular")
|
||||
@Operation(summary = "인기 매장 추천", description = "카테고리별 인기 매장을 추천합니다.")
|
||||
public ResponseEntity<List<RecommendStoreResponse>> recommendPopularStores(
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(defaultValue = "10") Integer limit) {
|
||||
|
||||
List<RecommendStoreResponse> recommendations = storeRecommendUseCase
|
||||
.recommendPopularStores(category, limit);
|
||||
return ResponseEntity.ok(recommendations);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.ktds.hi.recommend.infra.controller;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.in.TasteAnalysisUseCase;
|
||||
import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse;
|
||||
import com.ktds.hi.common.dto.SuccessResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 취향 분석 컨트롤러 클래스
|
||||
* 사용자 취향 분석 관련 API를 제공
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/recommend/taste")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "취향 분석 API", description = "사용자 취향 분석 관련 API")
|
||||
public class TasteAnalysisController {
|
||||
|
||||
private final TasteAnalysisUseCase tasteAnalysisUseCase;
|
||||
|
||||
/**
|
||||
* 사용자 취향 분석 조회 API
|
||||
*/
|
||||
@GetMapping("/analysis")
|
||||
@Operation(summary = "취향 분석 조회", description = "현재 로그인한 사용자의 취향 분석 결과를 조회합니다.")
|
||||
public ResponseEntity<TasteAnalysisResponse> getMemberTasteAnalysis(Authentication authentication) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
TasteAnalysisResponse analysis = tasteAnalysisUseCase.analyzeMemberTaste(memberId);
|
||||
return ResponseEntity.ok(analysis);
|
||||
}
|
||||
|
||||
/**
|
||||
* 취향 프로필 업데이트 API
|
||||
*/
|
||||
@PostMapping("/update")
|
||||
@Operation(summary = "취향 프로필 업데이트", description = "사용자의 리뷰 데이터를 기반으로 취향 프로필을 업데이트합니다.")
|
||||
public ResponseEntity<SuccessResponse> updateTasteProfile(Authentication authentication) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
tasteAnalysisUseCase.updateTasteProfile(memberId);
|
||||
return ResponseEntity.ok(SuccessResponse.of("취향 프로필이 업데이트되었습니다"));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.ktds.hi.recommend.infra.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 추천 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 추천 요청")
|
||||
public class RecommendStoreRequest {
|
||||
|
||||
@NotNull(message = "위도는 필수입니다")
|
||||
@Schema(description = "위도", example = "37.5665")
|
||||
private Double latitude;
|
||||
|
||||
@NotNull(message = "경도는 필수입니다")
|
||||
@Schema(description = "경도", example = "126.9780")
|
||||
private Double longitude;
|
||||
|
||||
@Schema(description = "검색 반경(미터)", example = "5000", defaultValue = "5000")
|
||||
private Integer radius = 5000;
|
||||
|
||||
@Schema(description = "선호 카테고리", example = "[\"한식\", \"일식\"]")
|
||||
private List<String> preferredCategories;
|
||||
|
||||
@Schema(description = "가격 범위", example = "MEDIUM")
|
||||
private String priceRange;
|
||||
|
||||
@Schema(description = "추천 개수", example = "10", defaultValue = "10")
|
||||
private Integer limit = 10;
|
||||
|
||||
/**
|
||||
* 유효성 검증
|
||||
*/
|
||||
public void validate() {
|
||||
if (latitude == null || longitude == null) {
|
||||
throw new IllegalArgumentException("위도와 경도는 필수입니다");
|
||||
}
|
||||
if (latitude < -90 || latitude > 90) {
|
||||
throw new IllegalArgumentException("위도는 -90과 90 사이여야 합니다");
|
||||
}
|
||||
if (longitude < -180 || longitude > 180) {
|
||||
throw new IllegalArgumentException("경도는 -180과 180 사이여야 합니다");
|
||||
}
|
||||
if (radius != null && radius <= 0) {
|
||||
throw new IllegalArgumentException("검색 반경은 0보다 커야 합니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.ktds.hi.recommend.infra.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 추천 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 추천 응답")
|
||||
public class RecommendStoreResponse {
|
||||
|
||||
@Schema(description = "매장 ID")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "매장명")
|
||||
private String storeName;
|
||||
|
||||
@Schema(description = "주소")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "카테고리")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "태그 목록")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "평점")
|
||||
private Double rating;
|
||||
|
||||
@Schema(description = "리뷰 수")
|
||||
private Integer reviewCount;
|
||||
|
||||
@Schema(description = "거리(미터)")
|
||||
private Double distance;
|
||||
|
||||
@Schema(description = "추천 점수")
|
||||
private Double recommendScore;
|
||||
|
||||
@Schema(description = "추천 이유")
|
||||
private String recommendReason;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.ktds.hi.recommend.infra.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 취향 분석 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "취향 분석 응답")
|
||||
public class TasteAnalysisResponse {
|
||||
|
||||
@Schema(description = "회원 ID")
|
||||
private Long memberId;
|
||||
|
||||
@Schema(description = "선호 카테고리")
|
||||
private List<String> preferredCategories;
|
||||
|
||||
@Schema(description = "최고 선호 카테고리")
|
||||
private String topCategory;
|
||||
|
||||
@Schema(description = "카테고리별 점수")
|
||||
private Map<String, Double> categoryScores;
|
||||
|
||||
@Schema(description = "선호 태그")
|
||||
private List<String> preferredTags;
|
||||
|
||||
@Schema(description = "가격 선호도")
|
||||
private Double pricePreference;
|
||||
|
||||
@Schema(description = "거리 선호도")
|
||||
private Double distancePreference;
|
||||
|
||||
@Schema(description = "분석 일시")
|
||||
private LocalDateTime analysisDate;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ktds.hi.recommend.infra.gateway;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.out.AiRecommendRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendStore;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 추천 어댑터 클래스
|
||||
* AI 기반 추천 기능을 구현 (현재는 Mock 구현)
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiRecommendAdapter implements AiRecommendRepository {
|
||||
|
||||
@Override
|
||||
public List<RecommendStore> recommendStoresByAI(Long memberId, Map<String, Object> preferences) {
|
||||
log.info("AI 기반 매장 추천 요청: memberId={}, preferences={}", memberId, preferences);
|
||||
|
||||
// Mock 구현 - 실제로는 AI 모델 API 호출
|
||||
return List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(1L)
|
||||
.storeName("AI 추천 매장 1")
|
||||
.address("서울시 강남구 역삼동")
|
||||
.category("한식")
|
||||
.tags(List.of("맛집", "깔끔", "한식"))
|
||||
.rating(4.5)
|
||||
.reviewCount(150)
|
||||
.distance(500.0)
|
||||
.recommendScore(92.0)
|
||||
.recommendType(RecommendType.AI_RECOMMENDATION)
|
||||
.recommendReason("사용자 취향과 92% 일치")
|
||||
.build(),
|
||||
|
||||
RecommendStore.builder()
|
||||
.storeId(2L)
|
||||
.storeName("AI 추천 매장 2")
|
||||
.address("서울시 강남구 논현동")
|
||||
.category("일식")
|
||||
.tags(List.of("초밥", "신선", "일식"))
|
||||
.rating(4.3)
|
||||
.reviewCount(89)
|
||||
.distance(800.0)
|
||||
.recommendScore(87.0)
|
||||
.recommendType(RecommendType.AI_RECOMMENDATION)
|
||||
.recommendReason("사용자가 선호하는 일식 카테고리")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecommendStore> recommendStoresBySimilarUsers(Long memberId) {
|
||||
log.info("유사 사용자 기반 추천 요청: memberId={}", memberId);
|
||||
|
||||
// Mock 구현
|
||||
return List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(3L)
|
||||
.storeName("유사 취향 추천 매장")
|
||||
.address("서울시 서초구 서초동")
|
||||
.category("양식")
|
||||
.tags(List.of("파스타", "분위기", "양식"))
|
||||
.rating(4.4)
|
||||
.reviewCount(203)
|
||||
.distance(1200.0)
|
||||
.recommendScore(85.0)
|
||||
.recommendType(RecommendType.SIMILAR_USER)
|
||||
.recommendReason("비슷한 취향의 사용자들이 좋아하는 매장")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecommendStore> recommendStoresByCollaborativeFiltering(Long memberId) {
|
||||
log.info("협업 필터링 추천 요청: memberId={}", memberId);
|
||||
|
||||
// Mock 구현
|
||||
return List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(4L)
|
||||
.storeName("협업 필터링 추천 매장")
|
||||
.address("서울시 마포구 홍대입구")
|
||||
.category("카페")
|
||||
.tags(List.of("커피", "디저트", "분위기"))
|
||||
.rating(4.2)
|
||||
.reviewCount(127)
|
||||
.distance(2500.0)
|
||||
.recommendScore(82.0)
|
||||
.recommendType(RecommendType.COLLABORATIVE_FILTERING)
|
||||
.recommendReason("사용자 행동 패턴 기반 추천")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.ktds.hi.recommend.infra.gateway;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.out.LocationRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.Location;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendStore;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 위치 서비스 어댑터 클래스
|
||||
* 위치 기반 서비스 기능을 구현 (현재는 Mock 구현)
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LocationServiceAdapter implements LocationRepository {
|
||||
|
||||
@Override
|
||||
public Location saveLocation(Location location) {
|
||||
log.info("위치 정보 저장: {}", location.getAddress());
|
||||
|
||||
// Mock 구현
|
||||
return Location.builder()
|
||||
.id(1L)
|
||||
.address(location.getAddress())
|
||||
.latitude(location.getLatitude())
|
||||
.longitude(location.getLongitude())
|
||||
.city("서울시")
|
||||
.district("강남구")
|
||||
.country("대한민국")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecommendStore> findStoresWithinRadius(Double latitude, Double longitude, Integer radius) {
|
||||
log.info("반경 내 매장 조회: lat={}, lon={}, radius={}", latitude, longitude, radius);
|
||||
|
||||
// Mock 구현
|
||||
return List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(5L)
|
||||
.storeName("근처 매장 1")
|
||||
.address("서울시 강남구 역삼동 123-45")
|
||||
.category("한식")
|
||||
.tags(List.of("근처", "맛집"))
|
||||
.rating(4.1)
|
||||
.reviewCount(95)
|
||||
.distance(300.0)
|
||||
.recommendScore(78.0)
|
||||
.recommendType(RecommendType.LOCATION_BASED)
|
||||
.recommendReason("현재 위치에서 300m 거리")
|
||||
.build(),
|
||||
|
||||
RecommendStore.builder()
|
||||
.storeId(6L)
|
||||
.storeName("근처 매장 2")
|
||||
.address("서울시 강남구 역삼동 678-90")
|
||||
.category("카페")
|
||||
.tags(List.of("커피", "디저트"))
|
||||
.rating(4.0)
|
||||
.reviewCount(67)
|
||||
.distance(450.0)
|
||||
.recommendScore(75.0)
|
||||
.recommendType(RecommendType.LOCATION_BASED)
|
||||
.recommendReason("현재 위치에서 450m 거리")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2) {
|
||||
// Haversine 공식을 사용한 거리 계산
|
||||
final int R = 6371; // 지구의 반지름 (km)
|
||||
|
||||
double latDistance = Math.toRadians(lat2 - lat1);
|
||||
double lonDistance = Math.toRadians(lon2 - lon1);
|
||||
|
||||
double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
|
||||
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||
* Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);
|
||||
|
||||
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
double distance = R * c * 1000; // 미터로 변환
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Location geocodeAddress(String address) {
|
||||
log.info("주소 좌표 변환 요청: {}", address);
|
||||
|
||||
// Mock 구현 - 실제로는 Google Maps API 등 사용
|
||||
return Location.builder()
|
||||
.address(address)
|
||||
.latitude(37.5665)
|
||||
.longitude(126.9780)
|
||||
.city("서울시")
|
||||
.district("중구")
|
||||
.country("대한민국")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.ktds.hi.recommend.infra.gateway;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.out.RecommendRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendHistory;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
import com.ktds.hi.recommend.infra.gateway.repository.RecommendHistoryJpaRepository;
|
||||
import com.ktds.hi.recommend.infra.gateway.repository.TasteProfileJpaRepository;
|
||||
import com.ktds.hi.recommend.infra.gateway.entity.RecommendHistoryEntity;
|
||||
import com.ktds.hi.recommend.infra.gateway.entity.TasteProfileEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 추천 리포지토리 어댑터 클래스
|
||||
* 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RecommendRepositoryAdapter implements RecommendRepository {
|
||||
|
||||
private final RecommendHistoryJpaRepository recommendHistoryJpaRepository;
|
||||
private final TasteProfileJpaRepository tasteProfileJpaRepository;
|
||||
|
||||
@Override
|
||||
public RecommendHistory saveRecommendHistory(RecommendHistory history) {
|
||||
RecommendHistoryEntity entity = toRecommendHistoryEntity(history);
|
||||
RecommendHistoryEntity savedEntity = recommendHistoryJpaRepository.save(entity);
|
||||
return toRecommendHistory(savedEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecommendHistory> findRecommendHistoriesByMemberId(Long memberId) {
|
||||
List<RecommendHistoryEntity> entities = recommendHistoryJpaRepository.findByMemberIdOrderByCreatedAtDesc(memberId);
|
||||
return entities.stream()
|
||||
.map(this::toRecommendHistory)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public TasteProfile saveTasteProfile(TasteProfile profile) {
|
||||
TasteProfileEntity entity = toTasteProfileEntity(profile);
|
||||
TasteProfileEntity savedEntity = tasteProfileJpaRepository.save(entity);
|
||||
return toTasteProfile(savedEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TasteProfile> findTasteProfileByMemberId(Long memberId) {
|
||||
return tasteProfileJpaRepository.findByMemberId(memberId)
|
||||
.map(this::toTasteProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티를 도메인으로 변환
|
||||
*/
|
||||
private RecommendHistory toRecommendHistory(RecommendHistoryEntity entity) {
|
||||
return RecommendHistory.builder()
|
||||
.id(entity.getId())
|
||||
.memberId(entity.getMemberId())
|
||||
.recommendedStoreIds(entity.getRecommendedStoreIdsList())
|
||||
.recommendType(entity.getRecommendType())
|
||||
.criteria(entity.getCriteria())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private TasteProfile toTasteProfile(TasteProfileEntity entity) {
|
||||
return TasteProfile.builder()
|
||||
.id(entity.getId())
|
||||
.memberId(entity.getMemberId())
|
||||
.preferredCategories(entity.getPreferredCategoriesList())
|
||||
.categoryScores(entity.getCategoryScoresMap())
|
||||
.preferredTags(entity.getPreferredTagsList())
|
||||
.behaviorPatterns(entity.getBehaviorPatternsMap())
|
||||
.pricePreference(entity.getPricePreference())
|
||||
.distancePreference(entity.getDistancePreference())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.updatedAt(entity.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인을 엔티티로 변환
|
||||
*/
|
||||
private RecommendHistoryEntity toRecommendHistoryEntity(RecommendHistory domain) {
|
||||
return RecommendHistoryEntity.builder()
|
||||
.id(domain.getId())
|
||||
.memberId(domain.getMemberId())
|
||||
.recommendedStoreIdsJson(domain.getRecommendedStoreIds().toString()) // JSON 변환 필요
|
||||
.recommendType(domain.getRecommendType())
|
||||
.criteria(domain.getCriteria())
|
||||
.build();
|
||||
}
|
||||
|
||||
private TasteProfileEntity toTasteProfileEntity(TasteProfile domain) {
|
||||
return TasteProfileEntity.builder()
|
||||
.id(domain.getId())
|
||||
.memberId(domain.getMemberId())
|
||||
.preferredCategoriesJson(domain.getPreferredCategories().toString()) // JSON 변환 필요
|
||||
.categoryScoresJson(domain.getCategoryScores().toString()) // JSON 변환 필요
|
||||
.preferredTagsJson(domain.getPreferredTags().toString()) // JSON 변환 필요
|
||||
.behaviorPatternsJson(domain.getBehaviorPatterns().toString()) // JSON 변환 필요
|
||||
.pricePreference(domain.getPricePreference())
|
||||
.distancePreference(domain.getDistancePreference())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.ktds.hi.recommend.infra.gateway;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.out.UserPreferenceRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteCategory;
|
||||
import com.ktds.hi.recommend.infra.gateway.repository.TasteProfileJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 사용자 선호도 어댑터 클래스
|
||||
* 사용자 취향 데이터 처리 기능을 구현
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserPreferenceAdapter implements UserPreferenceRepository {
|
||||
|
||||
private final TasteProfileJpaRepository tasteProfileJpaRepository;
|
||||
private final RecommendRepositoryAdapter recommendRepositoryAdapter;
|
||||
|
||||
@Override
|
||||
public Optional<TasteProfile> getMemberPreferences(Long memberId) {
|
||||
return recommendRepositoryAdapter.findTasteProfileByMemberId(memberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> analyzePreferencesFromReviews(Long memberId) {
|
||||
log.info("리뷰 기반 취향 분석 시작: memberId={}", memberId);
|
||||
|
||||
// Mock 구현 - 실제로는 리뷰 서비스 API 호출하여 분석
|
||||
return Map.of(
|
||||
"preferredCategories", List.of(TasteCategory.KOREAN, TasteCategory.JAPANESE),
|
||||
"categoryScores", Map.of(
|
||||
"한식", 85.0,
|
||||
"일식", 78.0,
|
||||
"양식", 65.0
|
||||
),
|
||||
"preferredTags", List.of("맛집", "깔끔", "친절"),
|
||||
"pricePreference", 60.0, // 0-100 점수
|
||||
"distancePreference", 70.0,
|
||||
"behaviorPatterns", Map.of(
|
||||
"weekendDining", true,
|
||||
"avgRating", 4.2,
|
||||
"reviewFrequency", "medium"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> findSimilarTasteMembers(Long memberId) {
|
||||
log.info("유사 취향 사용자 조회: memberId={}", memberId);
|
||||
|
||||
// Mock 구현 - 실제로는 ML 모델 또는 유사도 계산 알고리즘 사용
|
||||
return List.of(123L, 456L, 789L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TasteProfile updateTasteProfile(Long memberId, Map<String, Object> analysisData) {
|
||||
log.info("취향 프로필 업데이트: memberId={}", memberId);
|
||||
|
||||
// 기존 프로필 조회 또는 새로 생성
|
||||
Optional<TasteProfile> existingProfile = getMemberPreferences(memberId);
|
||||
|
||||
TasteProfile.TasteProfileBuilder builder = TasteProfile.builder()
|
||||
.memberId(memberId)
|
||||
.preferredCategories((List<TasteCategory>) analysisData.get("preferredCategories"))
|
||||
.categoryScores((Map<String, Double>) analysisData.get("categoryScores"))
|
||||
.preferredTags((List<String>) analysisData.get("preferredTags"))
|
||||
.behaviorPatterns((Map<String, Object>) analysisData.get("behaviorPatterns"))
|
||||
.pricePreference((Double) analysisData.get("pricePreference"))
|
||||
.distancePreference((Double) analysisData.get("distancePreference"))
|
||||
.updatedAt(LocalDateTime.now());
|
||||
|
||||
if (existingProfile.isPresent()) {
|
||||
builder.id(existingProfile.get().getId())
|
||||
.createdAt(existingProfile.get().getCreatedAt());
|
||||
} else {
|
||||
builder.createdAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
TasteProfile updatedProfile = builder.build();
|
||||
return recommendRepositoryAdapter.saveTasteProfile(updatedProfile);
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.ktds.hi.recommend.infra.gateway.entity;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 추천 히스토리 엔티티 클래스
|
||||
* 데이터베이스 recommend_history 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "recommend_history")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class RecommendHistoryEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "member_id", nullable = false)
|
||||
private Long memberId;
|
||||
|
||||
@Column(name = "recommended_store_ids_json", columnDefinition = "TEXT")
|
||||
private String recommendedStoreIdsJson;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "recommend_type", nullable = false)
|
||||
private RecommendType recommendType;
|
||||
|
||||
@Column(length = 500)
|
||||
private String criteria;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* JSON 문자열을 List로 변환
|
||||
*/
|
||||
public List<Long> getRecommendedStoreIdsList() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(recommendedStoreIdsJson, new TypeReference<List<Long>>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.ktds.hi.recommend.infra.gateway.entity;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteCategory;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 취향 프로필 엔티티 클래스
|
||||
* 데이터베이스 taste_profiles 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "taste_profiles")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class TasteProfileEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "member_id", nullable = false, unique = true)
|
||||
private Long memberId;
|
||||
|
||||
@Column(name = "preferred_categories_json", columnDefinition = "TEXT")
|
||||
private String preferredCategoriesJson;
|
||||
|
||||
@Column(name = "category_scores_json", columnDefinition = "TEXT")
|
||||
private String categoryScoresJson;
|
||||
|
||||
@Column(name = "preferred_tags_json", columnDefinition = "TEXT")
|
||||
private String preferredTagsJson;
|
||||
|
||||
@Column(name = "behavior_patterns_json", columnDefinition = "TEXT")
|
||||
private String behaviorPatternsJson;
|
||||
|
||||
@Column(name = "price_preference")
|
||||
private Double pricePreference;
|
||||
|
||||
@Column(name = "distance_preference")
|
||||
private Double distancePreference;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* JSON 문자열을 객체로 변환하는 메서드들
|
||||
*/
|
||||
public List<TasteCategory> getPreferredCategoriesList() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(preferredCategoriesJson, new TypeReference<List<TasteCategory>>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Double> getCategoryScoresMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(categoryScoresJson, new TypeReference<Map<String, Double>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getPreferredTagsList() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(preferredTagsJson, new TypeReference<List<String>>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> getBehaviorPatternsMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(behaviorPatternsJson, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ktds.hi.recommend.infra.gateway.repository;
|
||||
|
||||
import com.ktds.hi.recommend.infra.gateway.entity.RecommendHistoryEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 추천 히스토리 JPA 리포지토리 인터페이스
|
||||
* 추천 히스토리 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface RecommendHistoryJpaRepository extends JpaRepository<RecommendHistoryEntity, Long> {
|
||||
|
||||
/**
|
||||
* 회원 ID로 추천 히스토리 조회 (최신순)
|
||||
*/
|
||||
List<RecommendHistoryEntity> findByMemberIdOrderByCreatedAtDesc(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원 ID로 최신 추천 히스토리 조회
|
||||
*/
|
||||
RecommendHistoryEntity findTopByMemberIdOrderByCreatedAtDesc(Long memberId);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.ktds.hi.recommend.infra.gateway.repository;
|
||||
|
||||
import com.ktds.hi.recommend.infra.gateway.entity.TasteProfileEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 취향 프로필 JPA 리포지토리 인터페이스
|
||||
* 취향 프로필 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface TasteProfileJpaRepository extends JpaRepository<TasteProfileEntity, Long> {
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 프로필 조회
|
||||
*/
|
||||
Optional<TasteProfileEntity> findByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 프로필 존재 여부 확인
|
||||
*/
|
||||
boolean existsByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 프로필 삭제
|
||||
*/
|
||||
void deleteByMemberId(Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
server:
|
||||
port: ${RECOMMEND_SERVICE_PORT:8085}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: recommend-service
|
||||
|
||||
datasource:
|
||||
url: ${RECOMMEND_DB_URL:jdbc:postgresql://localhost:5432/hiorder_recommend}
|
||||
username: ${RECOMMEND_DB_USERNAME:hiorder_user}
|
||||
password: ${RECOMMEND_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
recommendation:
|
||||
cache-ttl: 3600 # 1시간
|
||||
max-recommendations: 20
|
||||
default-radius: 5000 # 5km
|
||||
|
||||
location:
|
||||
google-maps-api-key: ${GOOGLE_MAPS_API_KEY:}
|
||||
|
||||
hiorder-api:
|
||||
base-url: ${HIORDER_API_BASE_URL:https://api.hiorder.com}
|
||||
api-key: ${HIORDER_API_KEY:}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
Reference in New Issue
Block a user