This commit is contained in:
lsh9672
2025-06-11 16:31:06 +09:00
commit f0fbb47c51
164 changed files with 8667 additions and 0 deletions
@@ -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();
}
}
@@ -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();
}
}
@@ -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());
}
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}