1+ package com .wayble .server .explore .repository .recommend ;
2+
3+ import com .querydsl .core .BooleanBuilder ;
4+ import com .querydsl .core .types .dsl .Expressions ;
5+ import com .querydsl .core .types .dsl .NumberExpression ;
6+ import com .querydsl .jpa .impl .JPAQueryFactory ;
7+ import com .wayble .server .explore .dto .common .FacilityResponseDto ;
8+ import com .wayble .server .explore .dto .common .WaybleZoneInfoResponseDto ;
9+ import com .wayble .server .explore .dto .recommend .WaybleZoneRecommendResponseDto ;
10+ import com .wayble .server .explore .entity .EsWaybleZoneFacility ;
11+ import com .wayble .server .user .entity .User ;
12+ import com .wayble .server .user .entity .Gender ;
13+ import com .wayble .server .common .entity .AgeGroup ;
14+ import com .wayble .server .wayblezone .entity .WaybleZone ;
15+ import com .wayble .server .wayblezone .entity .WaybleZoneVisitLog ;
16+ import lombok .RequiredArgsConstructor ;
17+ import org .springframework .stereotype .Repository ;
18+
19+ import java .time .LocalDate ;
20+ import java .time .temporal .ChronoUnit ;
21+ import java .util .*;
22+ import java .util .stream .Collectors ;
23+
24+ import static com .wayble .server .wayblezone .entity .QWaybleZone .waybleZone ;
25+ import static com .wayble .server .wayblezone .entity .QWaybleZoneVisitLog .waybleZoneVisitLog ;
26+
27+ @ Repository
28+ @ RequiredArgsConstructor
29+ public class WaybleZoneQueryRecommendMysqlRepository {
30+
31+ private final JPAQueryFactory queryFactory ;
32+
33+ // === [가중치 설정] === //
34+ private static final double DISTANCE_WEIGHT = 0.55 ; // 거리 기반 점수 가중치
35+ private static final double SIMILARITY_WEIGHT = 0.15 ; // 유사 사용자 방문 이력 기반 점수 가중치
36+ private static final double RECENCY_WEIGHT = 0.3 ; // 최근 추천 내역 기반 감점 가중치
37+
38+ private static final int MAX_DAY_DIFF = 30 ; // 추천 감점 최대 기준일 (30일 전까지 고려)
39+
40+ public List <WaybleZoneRecommendResponseDto > searchPersonalWaybleZones (User user , double latitude , double longitude , int size ) {
41+
42+ AgeGroup userAgeGroup = AgeGroup .fromBirthDate (user .getBirthDate ());
43+ Gender userGender = user .getGender ();
44+
45+ // Step 1: 50km 반경 이내 장소들 조회 (MySQL Haversine 공식 사용)
46+ NumberExpression <Double > distanceExpression = calculateHaversineDistance (latitude , longitude );
47+
48+ List <WaybleZone > nearbyZones = queryFactory
49+ .selectFrom (waybleZone )
50+ .leftJoin (waybleZone .facility ).fetchJoin ()
51+ .where (distanceExpression .loe (50.0 )) // 50km 이내
52+ .orderBy (distanceExpression .asc ())
53+ .limit (100 ) // 상위 100개만 가져와서 성능 최적화
54+ .fetch ();
55+
56+ // Step 2: 최근 30일 이내 방문 로그 조회 (MySQL)
57+ LocalDate thirtyDaysAgo = LocalDate .now ().minusDays (30 );
58+
59+ List <WaybleZoneVisitLog > visitLogs = queryFactory
60+ .selectFrom (waybleZoneVisitLog )
61+ .where (waybleZoneVisitLog .visitedAt .goe (thirtyDaysAgo ))
62+ .limit (10000 )
63+ .fetch ();
64+
65+ // Step 3: zoneId 별로 유사 사용자 방문 횟수 가중치 계산
66+ Map <Long , Double > zoneVisitScoreMap = new HashMap <>();
67+
68+ for (WaybleZoneVisitLog log : visitLogs ) {
69+ double weight = 0.0 ;
70+ boolean ageMatch = log .getAgeGroup () == userAgeGroup ;
71+ boolean genderMatch = log .getGender () == userGender ;
72+
73+ if (ageMatch && genderMatch ) {
74+ weight = 1.0 ; // 성별, 연령 둘 다 일치
75+ } else if (ageMatch ) {
76+ weight = 0.7 ; // 연령만 일치
77+ } else if (genderMatch ) {
78+ weight = 0.2 ; // 성별만 일치
79+ }
80+
81+ zoneVisitScoreMap .merge (log .getZoneId (), weight , Double ::sum );
82+ }
83+
84+ // Step 4: 최근 추천 날짜 조회 (MySQL - 실제 추천 로그 테이블이 있다면)
85+ // 여기서는 간단히 빈 맵으로 처리 (실제로는 추천 로그 테이블에서 조회)
86+ Map <Long , LocalDate > recentRecommendDateMap = new HashMap <>();
87+
88+ // 실제 구현시에는 아래와 같이 추천 로그 테이블에서 조회
89+ /*
90+ List<RecommendLog> recommendLogs = queryFactory
91+ .selectFrom(recommendLog)
92+ .where(recommendLog.userId.eq(user.getId()))
93+ .limit(1000)
94+ .fetch();
95+
96+ Map<Long, LocalDate> recentRecommendDateMap = recommendLogs.stream()
97+ .collect(Collectors.toMap(
98+ RecommendLog::getZoneId,
99+ RecommendLog::getRecommendationDate,
100+ (existing, replacement) -> existing.isAfter(replacement) ? existing : replacement
101+ ));
102+ */
103+
104+ // Step 5: 각 장소마다 점수 계산 후 DTO로 변환
105+ return nearbyZones .stream ()
106+ .map (zone -> {
107+ // 거리 계산 (Java로 정확한 계산)
108+ double distanceKm = calculateHaversineDistanceJava (
109+ latitude , longitude ,
110+ zone .getAddress ().getLatitude (), zone .getAddress ().getLongitude ()
111+ );
112+
113+ // 거리 점수 계산 (가까울수록 높음)
114+ double distanceScore = (1.0 / (1.0 + distanceKm )) * DISTANCE_WEIGHT ;
115+
116+ // 유사도 점수 (비슷한 사용자 방문수 반영)
117+ double similarityScore = (zoneVisitScoreMap .getOrDefault (zone .getId (), 0.0 ) / 10.0 ) * SIMILARITY_WEIGHT ;
118+
119+ // 최근 추천일 기반 감점 계산
120+ double recencyScore = RECENCY_WEIGHT ;
121+ LocalDate lastRecommendDate = recentRecommendDateMap .get (zone .getId ());
122+
123+ if (lastRecommendDate != null ) {
124+ long daysSince = ChronoUnit .DAYS .between (lastRecommendDate , LocalDate .now ());
125+ double factor = 1.0 - Math .min (daysSince , MAX_DAY_DIFF ) / (double ) MAX_DAY_DIFF ; // 0~1
126+ recencyScore = RECENCY_WEIGHT * (1.0 - factor ); // days=0 -> 0점, days=30 -> full 점수
127+ }
128+
129+ double totalScore = distanceScore + similarityScore + recencyScore ;
130+
131+ WaybleZoneInfoResponseDto waybleZoneInfo = WaybleZoneInfoResponseDto .builder ()
132+ .zoneId (zone .getId ())
133+ .zoneName (zone .getZoneName ())
134+ .zoneType (zone .getZoneType ())
135+ .thumbnailImageUrl (zone .getMainImageUrl ())
136+ .address (zone .getAddress ().toFullAddress ())
137+ .latitude (zone .getAddress ().getLatitude ())
138+ .longitude (zone .getAddress ().getLongitude ())
139+ .averageRating (zone .getRating ())
140+ .reviewCount (zone .getReviewCount ())
141+ .facility (zone .getFacility () != null ?
142+ FacilityResponseDto .from (EsWaybleZoneFacility .from (zone .getFacility ())) : null )
143+ .build ();
144+
145+ return WaybleZoneRecommendResponseDto .builder ()
146+ .waybleZoneInfo (waybleZoneInfo )
147+ .distanceScore (distanceScore )
148+ .similarityScore (similarityScore )
149+ .recencyScore (recencyScore )
150+ .totalScore (totalScore )
151+ .build ();
152+ })
153+ .sorted (Comparator .comparingDouble (WaybleZoneRecommendResponseDto ::totalScore ).reversed ()) // 점수 내림차순 정렬
154+ .limit (size ) // 상위 size 개수만 반환
155+ .toList ();
156+ }
157+
158+ /**
159+ * Haversine 거리 계산 (QueryDSL Expression)
160+ */
161+ private NumberExpression <Double > calculateHaversineDistance (double userLat , double userLon ) {
162+ // 지구 반지름 (km)
163+ final double EARTH_RADIUS = 6371.0 ;
164+
165+ return Expressions .numberTemplate (Double .class ,
166+ "{0} * 2 * ASIN(SQRT(" +
167+ "POWER(SIN(RADIANS({1} - {2}) / 2), 2) + " +
168+ "COS(RADIANS({2})) * COS(RADIANS({1})) * " +
169+ "POWER(SIN(RADIANS({3} - {4}) / 2), 2)" +
170+ "))" ,
171+ EARTH_RADIUS ,
172+ waybleZone .address .latitude ,
173+ userLat ,
174+ waybleZone .address .longitude ,
175+ userLon
176+ );
177+ }
178+
179+ /**
180+ * Haversine 거리 계산 (Java 구현)
181+ */
182+ private double calculateHaversineDistanceJava (double lat1 , double lon1 , double lat2 , double lon2 ) {
183+ final double R = 6371 ; // 지구 반지름 (km)
184+ double dLat = Math .toRadians (lat2 - lat1 );
185+ double dLon = Math .toRadians (lon2 - lon1 );
186+ double a = Math .sin (dLat /2 ) * Math .sin (dLat /2 )
187+ + Math .cos (Math .toRadians (lat1 )) * Math .cos (Math .toRadians (lat2 ))
188+ * Math .sin (dLon /2 ) * Math .sin (dLon /2 );
189+ double c = 2 * Math .atan2 (Math .sqrt (a ), Math .sqrt (1 -a ));
190+ return R * c ;
191+ }
192+ }
0 commit comments