diff --git a/src/main/java/com/wayble/server/common/config/ElasticsearchConfig.java b/src/main/java/com/wayble/server/common/config/ElasticsearchConfig.java index 2e08054d..546b80d4 100644 --- a/src/main/java/com/wayble/server/common/config/ElasticsearchConfig.java +++ b/src/main/java/com/wayble/server/common/config/ElasticsearchConfig.java @@ -4,7 +4,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.elasticsearch.client.ClientConfiguration; import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration; -import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; @Configuration public class ElasticsearchConfig extends ElasticsearchConfiguration { diff --git a/src/main/java/com/wayble/server/explore/controller/WaybleZoneRecommendController.java b/src/main/java/com/wayble/server/explore/controller/WaybleZoneRecommendController.java index f3502256..a757b94d 100644 --- a/src/main/java/com/wayble/server/explore/controller/WaybleZoneRecommendController.java +++ b/src/main/java/com/wayble/server/explore/controller/WaybleZoneRecommendController.java @@ -1,14 +1,15 @@ package com.wayble.server.explore.controller; import com.wayble.server.common.response.CommonResponse; +import com.wayble.server.explore.dto.recommend.WaybleZoneRecommendConditionDto; import com.wayble.server.explore.dto.recommend.WaybleZoneRecommendResponseDto; import com.wayble.server.explore.service.WaybleZoneRecommendService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.util.List; @RestController @RequiredArgsConstructor @@ -18,10 +19,17 @@ public class WaybleZoneRecommendController { private final WaybleZoneRecommendService waybleZoneRecommendService; - @GetMapping("/{userId}") - public CommonResponse getWaybleZonePersonalRecommend( - @PathVariable("userId") Long userId) { - WaybleZoneRecommendResponseDto dto = waybleZoneRecommendService.getWaybleZonePersonalRecommend(userId); - return CommonResponse.success(dto); + @GetMapping() + public CommonResponse> getWaybleZonePersonalRecommend( + @Valid @ModelAttribute WaybleZoneRecommendConditionDto conditionDto, + @RequestParam(name = "size", defaultValue = "1") int size) { + + List result = waybleZoneRecommendService.getWaybleZonePersonalRecommend( + conditionDto.userId(), + conditionDto.latitude(), + conditionDto.longitude(), + size + ); + return CommonResponse.success(result); } } diff --git a/src/main/java/com/wayble/server/explore/dto/recommend/WaybleZoneRecommendConditionDto.java b/src/main/java/com/wayble/server/explore/dto/recommend/WaybleZoneRecommendConditionDto.java new file mode 100644 index 00000000..a4fa46cd --- /dev/null +++ b/src/main/java/com/wayble/server/explore/dto/recommend/WaybleZoneRecommendConditionDto.java @@ -0,0 +1,23 @@ +package com.wayble.server.explore.dto.recommend; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record WaybleZoneRecommendConditionDto( + @DecimalMin(value = "-90.0", message = "위도는 -90.0 이상이어야 합니다.") + @DecimalMax(value = "90.0", message = "위도는 90.0 이하여야 합니다.") + @NotNull(message = "위도 입력은 필수입니다.") + Double latitude, + + @DecimalMin(value = "-180.0", message = "경도는 -180.0 이상이어야 합니다.") + @DecimalMax(value = "180.0", message = "경도는 180.0 이하여야 합니다.") + @NotNull(message = "경도 입력은 필수입니다.") + Double longitude, + + @NotNull(message = "유저 ID는 필수입니다.") + Long userId +) { +} diff --git a/src/main/java/com/wayble/server/explore/dto/recommend/WaybleZoneRecommendResponseDto.java b/src/main/java/com/wayble/server/explore/dto/recommend/WaybleZoneRecommendResponseDto.java index b9eabbbe..051c0495 100644 --- a/src/main/java/com/wayble/server/explore/dto/recommend/WaybleZoneRecommendResponseDto.java +++ b/src/main/java/com/wayble/server/explore/dto/recommend/WaybleZoneRecommendResponseDto.java @@ -1,8 +1,51 @@ package com.wayble.server.explore.dto.recommend; +import com.wayble.server.explore.entity.WaybleZoneDocument; +import com.wayble.server.wayblezone.entity.WaybleZoneType; +import lombok.Builder; + +@Builder public record WaybleZoneRecommendResponseDto( - String username + Long zoneId, + + String zoneName, + + WaybleZoneType zoneType, + + String thumbnailImageUrl, + + Double latitude, + + Double longitude, + + Double averageRating, + + Long reviewCount, + + Double distanceScore, + + Double similarityScore, + + Double recencyScore, + + Double totalScore ) { + public static WaybleZoneRecommendResponseDto from(WaybleZoneDocument waybleZoneDocument) { + return WaybleZoneRecommendResponseDto.builder() + .zoneId(waybleZoneDocument.getZoneId()) + .zoneName(waybleZoneDocument.getZoneName()) + .zoneType(waybleZoneDocument.getZoneType()) + .thumbnailImageUrl(waybleZoneDocument.getThumbnailImageUrl()) + .averageRating(waybleZoneDocument.getAverageRating()) + .reviewCount(waybleZoneDocument.getReviewCount()) + .latitude(waybleZoneDocument.getAddress().getLocation().getLat()) + .longitude(waybleZoneDocument.getAddress().getLocation().getLon()) + .distanceScore(0.0) + .similarityScore(0.0) + .recencyScore(0.0) + .totalScore(0.0) + .build(); + } } diff --git a/src/main/java/com/wayble/server/explore/entity/RecommendLogDocument.java b/src/main/java/com/wayble/server/explore/entity/RecommendLogDocument.java new file mode 100644 index 00000000..6c8cd087 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/entity/RecommendLogDocument.java @@ -0,0 +1,40 @@ +package com.wayble.server.explore.entity; +import org.springframework.data.annotation.Id; +import lombok.*; +import org.springframework.data.elasticsearch.annotations.DateFormat; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; + +import java.time.LocalDate; + +@ToString +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Document(indexName = "recommend_log") +public class RecommendLogDocument { + + @Id + @Field(name = "id") + private String id; + + private Long userId; + + private Long zoneId; + + @Field(type = FieldType.Date, format = DateFormat.date) + private LocalDate recommendationDate; + + private Long recommendCount; + + public void updateRecommendLog(LocalDate recommendationDate, Long recommendCount) { + if(recommendationDate != null) { + this.recommendationDate = recommendationDate; + } + if(recommendCount != null) { + this.recommendCount = recommendCount; + } + } +} diff --git a/src/main/java/com/wayble/server/explore/entity/WaybleZoneVisitLogDocument.java b/src/main/java/com/wayble/server/explore/entity/WaybleZoneVisitLogDocument.java index cce2a468..1de64b5d 100644 --- a/src/main/java/com/wayble/server/explore/entity/WaybleZoneVisitLogDocument.java +++ b/src/main/java/com/wayble/server/explore/entity/WaybleZoneVisitLogDocument.java @@ -19,7 +19,7 @@ public class WaybleZoneVisitLogDocument { @Id @Field(name = "id") - private Long logId; + private String logId; private Long userId; @@ -31,7 +31,7 @@ public class WaybleZoneVisitLogDocument { public static WaybleZoneVisitLogDocument fromEntity(User user, Long zoneId) { return WaybleZoneVisitLogDocument.builder() - .logId(UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE) + .logId(UUID.randomUUID().toString()) .userId(user.getId()) .zoneId(zoneId) .gender(user.getGender()) diff --git a/src/main/java/com/wayble/server/explore/exception/RecommendErrorCase.java b/src/main/java/com/wayble/server/explore/exception/RecommendErrorCase.java index 586a8f6c..4e991581 100644 --- a/src/main/java/com/wayble/server/explore/exception/RecommendErrorCase.java +++ b/src/main/java/com/wayble/server/explore/exception/RecommendErrorCase.java @@ -8,7 +8,11 @@ @RequiredArgsConstructor public enum RecommendErrorCase implements ErrorCase { - INVALID_USER(400, 6001, "잘못된 유저 정보입니다."); + INVALID_USER(400, 6001, "잘못된 유저 정보입니다."), + + RECOMMEND_LOG_NOT_EXIST(400, 6002, "해당하는 추천 기록이 존재하지 않습니다."), + + WAYBLE_ZONE_NOT_EXIST(400, 6003, "추천 기록에 해당하는 웨이블존이 존재하지 않습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/wayble/server/explore/exception/VisitLogErrorCase.java b/src/main/java/com/wayble/server/explore/exception/VisitLogErrorCase.java new file mode 100644 index 00000000..a369e72f --- /dev/null +++ b/src/main/java/com/wayble/server/explore/exception/VisitLogErrorCase.java @@ -0,0 +1,17 @@ +package com.wayble.server.explore.exception; + +import com.wayble.server.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum VisitLogErrorCase implements ErrorCase { + + USER_NOT_EXIST(400, 7001, "해당하는 유저가 존재하지 않습니다."), + ZONE_NOT_EXIST(400, 7002, "해당하는 웨이블존이 존재하지 않습니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/wayble/server/explore/repository/RecommendLogDocumentRepository.java b/src/main/java/com/wayble/server/explore/repository/RecommendLogDocumentRepository.java new file mode 100644 index 00000000..857d1376 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/repository/RecommendLogDocumentRepository.java @@ -0,0 +1,15 @@ +package com.wayble.server.explore.repository; + +import com.wayble.server.explore.entity.RecommendLogDocument; +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import java.time.LocalDate; +import java.util.Optional; + +public interface RecommendLogDocumentRepository extends ElasticsearchRepository { + Optional findByUserIdAndZoneId(Long userId, Long zoneId); + + boolean existsByUserIdAndZoneId(Long userId, Long zoneId); + + Optional findByUserIdAndRecommendationDate(Long userId, LocalDate recommendationDate); +} diff --git a/src/main/java/com/wayble/server/explore/repository/WaybleZoneVisitLogDocumentRepository.java b/src/main/java/com/wayble/server/explore/repository/WaybleZoneVisitLogDocumentRepository.java index 1bd0e382..d9af5cc8 100644 --- a/src/main/java/com/wayble/server/explore/repository/WaybleZoneVisitLogDocumentRepository.java +++ b/src/main/java/com/wayble/server/explore/repository/WaybleZoneVisitLogDocumentRepository.java @@ -3,7 +3,10 @@ import com.wayble.server.explore.entity.WaybleZoneVisitLogDocument; import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; +import java.util.List; -public interface WaybleZoneVisitLogDocumentRepository extends ElasticsearchRepository{ + +public interface WaybleZoneVisitLogDocumentRepository extends ElasticsearchRepository{ + List findAll(); } diff --git a/src/main/java/com/wayble/server/explore/repository/recommend/WaybleZoneQueryRecommendRepository.java b/src/main/java/com/wayble/server/explore/repository/recommend/WaybleZoneQueryRecommendRepository.java index 2d38f2f1..8a2d65eb 100644 --- a/src/main/java/com/wayble/server/explore/repository/recommend/WaybleZoneQueryRecommendRepository.java +++ b/src/main/java/com/wayble/server/explore/repository/recommend/WaybleZoneQueryRecommendRepository.java @@ -1,18 +1,152 @@ package com.wayble.server.explore.repository.recommend; +import co.elastic.clients.elasticsearch._types.GeoLocation; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; import com.wayble.server.explore.dto.recommend.WaybleZoneRecommendResponseDto; +import com.wayble.server.explore.entity.RecommendLogDocument; +import com.wayble.server.explore.entity.WaybleZoneDocument; +import com.wayble.server.explore.entity.WaybleZoneVisitLogDocument; import com.wayble.server.user.entity.User; +import com.wayble.server.user.entity.Gender; +import com.wayble.server.explore.entity.AgeGroup; import lombok.RequiredArgsConstructor; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.stereotype.Repository; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + @Repository @RequiredArgsConstructor public class WaybleZoneQueryRecommendRepository { private final ElasticsearchOperations operations; - public WaybleZoneRecommendResponseDto searchPersonalWaybleZone(User user) { - return null; + private static final IndexCoordinates ZONE_INDEX = IndexCoordinates.of("wayble_zone"); + private static final IndexCoordinates LOG_INDEX = IndexCoordinates.of("wayble_zone_visit_log"); + private static final IndexCoordinates RECOMMEND_LOG_INDEX = IndexCoordinates.of("recommend_log"); + + + // === [가중치 설정] === // + private static final double DISTANCE_WEIGHT = 0.55; // 거리 기반 점수 가중치 + private static final double SIMILARITY_WEIGHT = 0.15; // 유사 사용자 방문 이력 기반 점수 가중치 + private static final double RECENCY_WEIGHT = 0.3; // 최근 추천 내역 기반 감점 가중치 + + private static final int MAX_DAY_DIFF = 30; // 추천 감점 최대 기준일 (30일 전까지 고려) + + public List searchPersonalWaybleZones(User user, double latitude, double longitude, int size) { + + AgeGroup userAgeGroup = AgeGroup.fromBirthDate(user.getBirthDate()); + Gender userGender = user.getGender(); + + // 사용자의 위치 기준으로 50km 반경 이내 장소들 조회 + Query geoQuery = Query.of(q -> q + .bool(b -> b + .filter(f -> f.geoDistance(gd -> gd + .field("address.location") + .location(loc -> loc.latlon(ll -> ll.lat(latitude).lon(longitude))) + .distance("50km"))) + ) + ); + + // 거리 기준 정렬하여 최대 100개 결과 조회 + NativeQuery nativeQuery = NativeQuery.builder() + .withQuery(geoQuery) + .withSort(s -> s.geoDistance(gds -> gds + .field("address.location") + .location(GeoLocation.of(gl -> gl.latlon(ll -> ll.lat(latitude).lon(longitude)))) + .order(SortOrder.Asc))) + .withMaxResults(100) + .build(); + + SearchHits zoneHits = operations.search(nativeQuery, WaybleZoneDocument.class, ZONE_INDEX); + + // 전체 방문 로그를 최대 10,000건까지 조회 + NativeQuery logQuery = NativeQuery.builder() + .withMaxResults(10000) + .build(); + + SearchHits logHits = operations.search(logQuery, WaybleZoneVisitLogDocument.class, LOG_INDEX); + + // zoneId 별로 유사 사용자 방문 횟수 가중치 계산 + Map zoneVisitScoreMap = new HashMap<>(); + + for (var hit : logHits) { + WaybleZoneVisitLogDocument log = hit.getContent(); + + double weight = 0.0; + boolean ageMatch = log.getAgeGroup() == userAgeGroup; + boolean genderMatch = log.getGender() == userGender; + + if (ageMatch && genderMatch) { + weight = 1.0; // 성별, 연령 둘 다 일치 + } else if (ageMatch) { + weight = 0.7; // 연령만 일치 + } else if (genderMatch) { + weight = 0.2; // 성별만 일치 + } + + zoneVisitScoreMap.merge(log.getZoneId(), weight, Double::sum); + } + + // 최근 추천 날짜 조회 -> 가까울수록 감점 + NativeQuery recommendLogQuery = NativeQuery.builder() + .withQuery(Query.of(q -> q.term(t -> t.field("userId").value(user.getId())))) + .withMaxResults(1000) + .build(); + + SearchHits recommendHits = operations.search(recommendLogQuery, RecommendLogDocument.class, RECOMMEND_LOG_INDEX); + Map recentRecommendDateMap = recommendHits.stream() + .map(hit -> hit.getContent()) + .collect(Collectors.toMap(RecommendLogDocument::getZoneId, RecommendLogDocument::getRecommendationDate)); + + + // 각 장소마다 점수 계산 후 DTO로 변환 + return zoneHits.stream() + .map(hit -> { + WaybleZoneDocument zone = hit.getContent(); + + // 거리 점수 계산 (가까울수록 높음) + double distanceScore = (1.0 / (1.0 + ((Double) hit.getSortValues().get(0) / 1000.0))) * DISTANCE_WEIGHT; + + // 유사도 점수 (비슷한 사용자 방문수 반영) + double similarityScore = (zoneVisitScoreMap.getOrDefault(zone.getZoneId(), 0.0) / 10.0) * SIMILARITY_WEIGHT; + + // 최근 추천일 기반 감점 계산 + double recencyScore = RECENCY_WEIGHT; + LocalDate lastRecommendDate = recentRecommendDateMap.get(zone.getZoneId()); + + if (lastRecommendDate != null) { + long daysSince = ChronoUnit.DAYS.between(lastRecommendDate, LocalDate.now()); + double factor = 1.0 - Math.min(daysSince, MAX_DAY_DIFF) / (double) MAX_DAY_DIFF; // 0~1 + recencyScore = RECENCY_WEIGHT * (1.0 - factor); // days=0 -> 0점, days=30 -> full 점수 + } + + double totalScore = distanceScore + similarityScore + recencyScore; + + return WaybleZoneRecommendResponseDto.builder() + .zoneId(zone.getZoneId()) + .zoneName(zone.getZoneName()) + .zoneType(zone.getZoneType()) + .thumbnailImageUrl(zone.getThumbnailImageUrl()) + .latitude(zone.getAddress().getLocation().getLat()) + .longitude(zone.getAddress().getLocation().getLon()) + .averageRating(zone.getAverageRating()) + .reviewCount(zone.getReviewCount()) + .distanceScore(distanceScore) + .similarityScore(similarityScore) + .recencyScore(recencyScore) + .totalScore(totalScore) + .build(); + }) + .sorted(Comparator.comparingDouble(WaybleZoneRecommendResponseDto::totalScore).reversed()) // 점수 내림차순 정렬 + .limit(size) // 상위 size 개수만 반환 + .toList(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java b/src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java index 643c0e10..05c66f38 100644 --- a/src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java +++ b/src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java @@ -35,10 +35,10 @@ public Slice searchWaybleZonesByCondition(WaybleZon double radius = cond.radiusKm() != null ? cond.radiusKm() : 50.0; String radiusWithUnit = radius + "km"; // The new client often uses string representation for distance - // 1) Build the query using the new lambda-based builders + // 필터 및 조건 정의 Query query = Query.of(q -> q .bool(b -> { - // Must clause for zoneType if it exists + // zoneType이 존재하면 must 조건으로 추가 if (cond.zoneType() != null) { b.must(m -> m .term(t -> t @@ -47,7 +47,7 @@ public Slice searchWaybleZonesByCondition(WaybleZon ) ); } - // Must clause for name if it exists and is not blank + // zoneName이 비어있지 않으면 match 조건으로 추가 if (cond.zoneName() != null && !cond.zoneName().isBlank()) { b.must(m -> m .match(mp -> mp @@ -56,7 +56,7 @@ public Slice searchWaybleZonesByCondition(WaybleZon ) ); } - // Filter by geo distance + // 위치 기반 필터 조건: 중심 좌표 기준 반경 필터링 b.filter(f -> f .geoDistance(gd -> gd .field("address.location") @@ -73,7 +73,7 @@ public Slice searchWaybleZonesByCondition(WaybleZon }) ); - // 2) Build the sort options using the new builder + // 정렬 옵션 설정: 거리 기준 오름차순 정렬 SortOptions geoSort = SortOptions.of(s -> s .geoDistance(gds -> gds .field("address.location") @@ -87,21 +87,21 @@ public Slice searchWaybleZonesByCondition(WaybleZon ) ); - // 3) Combine into a NativeQuery + // NativeQuery 구성: 쿼리 + 정렬 + 페이징 정보 포함 NativeQuery nativeQuery = NativeQuery.builder() .withQuery(query) .withSort(geoSort) .withPageable(PageRequest.of( pageable.getPageNumber(), - fetchSize + fetchSize // 다음 페이지 유무를 판단하기 위해 +1 해서 조회 )) .build(); - // 4) Execute the search + // 실제 검색 수행 SearchHits hits = operations.search(nativeQuery, WaybleZoneDocument.class, INDEX); - // 5) Map to DTO: The distance in sortValues is still accessible in the same way + // 검색 결과를 DTO로 매핑 List dtos = hits.stream() .map(hit -> { WaybleZoneDocument doc = hit.getContent(); @@ -113,6 +113,7 @@ public Slice searchWaybleZonesByCondition(WaybleZon }) .toList(); + // 다음 페이지가 존재하는지 여부 판단 boolean hasNext = dtos.size() > pageable.getPageSize(); if (hasNext) { dtos = dtos.subList(0, pageable.getPageSize()); diff --git a/src/main/java/com/wayble/server/explore/service/WaybleZoneRecommendService.java b/src/main/java/com/wayble/server/explore/service/WaybleZoneRecommendService.java index 48e2f1a5..de826816 100644 --- a/src/main/java/com/wayble/server/explore/service/WaybleZoneRecommendService.java +++ b/src/main/java/com/wayble/server/explore/service/WaybleZoneRecommendService.java @@ -1,5 +1,9 @@ package com.wayble.server.explore.service; +import com.wayble.server.explore.entity.RecommendLogDocument; +import com.wayble.server.explore.entity.WaybleZoneDocument; +import com.wayble.server.explore.repository.RecommendLogDocumentRepository; +import com.wayble.server.explore.repository.WaybleZoneDocumentRepository; import com.wayble.server.explore.repository.recommend.WaybleZoneQueryRecommendRepository; import com.wayble.server.common.exception.ApplicationException; import com.wayble.server.explore.dto.recommend.WaybleZoneRecommendResponseDto; @@ -8,19 +12,89 @@ import com.wayble.server.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; @Service @RequiredArgsConstructor +@Transactional public class WaybleZoneRecommendService { private final WaybleZoneQueryRecommendRepository waybleZoneRecommendRepository; + private final RecommendLogDocumentRepository recommendLogDocumentRepository; + + private final WaybleZoneDocumentRepository waybleZoneDocumentRepository; + private final UserRepository userRepository; - public WaybleZoneRecommendResponseDto getWaybleZonePersonalRecommend(Long userId) { + public List getWaybleZonePersonalRecommend(Long userId, double latitude, double longitude, int size) { User user = userRepository.findById(userId) .orElseThrow(() -> new ApplicationException(RecommendErrorCase.INVALID_USER)); - return waybleZoneRecommendRepository.searchPersonalWaybleZone(user); + Optional todayRecommendZone = getTodayRecommendZone(userId); + if(size == 1 && todayRecommendZone.isPresent()) { + return List.of(todayRecommendZone.get()); + } + + List recommendResponseDtoList = waybleZoneRecommendRepository.searchPersonalWaybleZones(user, latitude, longitude, size); + + if (size == 1 && !recommendResponseDtoList.isEmpty()) { + Long zoneId = recommendResponseDtoList.get(0).zoneId(); + + boolean logExist = recommendLogDocumentRepository.existsByUserIdAndZoneId(userId, zoneId); + if (logExist) { + updateRecommendLog(userId, zoneId); + } else { + saveRecommendLog(userId, zoneId); + } + } + + return recommendResponseDtoList; + } + + public Optional getTodayRecommendZone(Long userId) { + LocalDate today = LocalDate.now(); + Optional recommendLogDocument = recommendLogDocumentRepository.findByUserIdAndRecommendationDate(userId, today); + + if(recommendLogDocument.isPresent()) { + Long zoneId = recommendLogDocument.get().getZoneId(); + WaybleZoneDocument waybleZoneDocument = waybleZoneDocumentRepository.findById(zoneId) + .orElseThrow(() -> new ApplicationException(RecommendErrorCase.WAYBLE_ZONE_NOT_EXIST)); + + return Optional.of(WaybleZoneRecommendResponseDto.from(waybleZoneDocument)); + } else { + return Optional.empty(); + } + } + + public void saveRecommendLog(Long userId, Long zoneId) { + String logId = UUID.randomUUID().toString(); + LocalDate dateNow = LocalDate.now(); + + RecommendLogDocument recommendLogDocument = RecommendLogDocument + .builder() + .id(logId) + .userId(userId) + .zoneId(zoneId) + .recommendationDate(dateNow) + .recommendCount(1L) + .build(); + + recommendLogDocumentRepository.save(recommendLogDocument); + } + + public void updateRecommendLog(Long userId, Long zoneId) { + RecommendLogDocument recommendLogDocument = recommendLogDocumentRepository.findByUserIdAndZoneId(userId, zoneId) + .orElseThrow(() -> new ApplicationException(RecommendErrorCase.RECOMMEND_LOG_NOT_EXIST)); + + Long recommendCount = recommendLogDocument.getRecommendCount() + 1; + recommendLogDocument.updateRecommendLog(LocalDate.now(), recommendCount); + + recommendLogDocumentRepository.save(recommendLogDocument); } } diff --git a/src/main/java/com/wayble/server/explore/service/WaybleZoneVisitLogService.java b/src/main/java/com/wayble/server/explore/service/WaybleZoneVisitLogService.java new file mode 100644 index 00000000..cb81e6eb --- /dev/null +++ b/src/main/java/com/wayble/server/explore/service/WaybleZoneVisitLogService.java @@ -0,0 +1,33 @@ +package com.wayble.server.explore.service; + +import com.wayble.server.common.exception.ApplicationException; +import com.wayble.server.explore.entity.WaybleZoneVisitLogDocument; +import com.wayble.server.explore.exception.VisitLogErrorCase; +import com.wayble.server.explore.repository.WaybleZoneVisitLogDocumentRepository; +import com.wayble.server.user.entity.User; +import com.wayble.server.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class WaybleZoneVisitLogService { + + private final WaybleZoneVisitLogDocumentRepository visitLogDocumentRepository; + + private final UserRepository userRepository; + + public void saveVisitLog(Long userId, Long zoneId) { + if(userId == null || zoneId == null) { + return; + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(VisitLogErrorCase.USER_NOT_EXIST)); + + WaybleZoneVisitLogDocument visitLogDocument = WaybleZoneVisitLogDocument.fromEntity(user, zoneId); + visitLogDocumentRepository.save(visitLogDocument); + } +} diff --git a/src/main/java/com/wayble/server/wayblezone/controller/WaybleZoneController.java b/src/main/java/com/wayble/server/wayblezone/controller/WaybleZoneController.java index 2bb3f088..a64923a1 100644 --- a/src/main/java/com/wayble/server/wayblezone/controller/WaybleZoneController.java +++ b/src/main/java/com/wayble/server/wayblezone/controller/WaybleZoneController.java @@ -1,6 +1,7 @@ package com.wayble.server.wayblezone.controller; import com.wayble.server.common.response.CommonResponse; +import com.wayble.server.explore.service.WaybleZoneVisitLogService; import com.wayble.server.wayblezone.dto.WaybleZoneDetailResponseDto; import com.wayble.server.wayblezone.dto.WaybleZoneListResponseDto; import com.wayble.server.wayblezone.service.WaybleZoneService; @@ -23,6 +24,8 @@ public class WaybleZoneController { private final WaybleZoneService waybleZoneService; + private final WaybleZoneVisitLogService waybleZoneVisitLogService; + @GetMapping @Operation( summary = "웨이블존 목록 조회", @@ -52,6 +55,9 @@ public CommonResponse> getWaybleZoneList( public CommonResponse getWaybleZoneDetail( @PathVariable @NotNull Long waybleZoneId ) { + + // TODO: JWT에서 userId 추출해서 상세 조회 기록 남기기 + // waybleZoneVisitLogService.saveVisitLog(null, waybleZoneId); return CommonResponse.success(waybleZoneService.getWaybleZoneDetail(waybleZoneId)); } } \ No newline at end of file diff --git a/src/test/java/com/wayble/server/explore/WaybleZoneRecommendApiIntegrationTest.java b/src/test/java/com/wayble/server/explore/WaybleZoneRecommendApiIntegrationTest.java new file mode 100644 index 00000000..61b09b63 --- /dev/null +++ b/src/test/java/com/wayble/server/explore/WaybleZoneRecommendApiIntegrationTest.java @@ -0,0 +1,376 @@ +package com.wayble.server.explore; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wayble.server.common.entity.Address; +import com.wayble.server.explore.dto.recommend.WaybleZoneRecommendResponseDto; +import com.wayble.server.explore.dto.search.WaybleZoneDocumentRegisterDto; +import com.wayble.server.explore.entity.AgeGroup; +import com.wayble.server.explore.entity.RecommendLogDocument; +import com.wayble.server.explore.entity.WaybleZoneDocument; +import com.wayble.server.explore.entity.WaybleZoneVisitLogDocument; +import com.wayble.server.explore.repository.RecommendLogDocumentRepository; +import com.wayble.server.explore.repository.WaybleZoneDocumentRepository; +import com.wayble.server.explore.repository.WaybleZoneVisitLogDocumentRepository; +import com.wayble.server.explore.repository.recommend.WaybleZoneQueryRecommendRepository; +import com.wayble.server.user.entity.Gender; +import com.wayble.server.user.entity.LoginType; +import com.wayble.server.user.entity.User; +import com.wayble.server.user.entity.UserType; +import com.wayble.server.user.repository.UserRepository; +import com.wayble.server.wayblezone.entity.WaybleZoneType; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@AutoConfigureMockMvc +public class WaybleZoneRecommendApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private WaybleZoneQueryRecommendRepository waybleZoneRecommendRepository; + + @Autowired + private WaybleZoneDocumentRepository waybleZoneDocumentRepository; + + @Autowired + private WaybleZoneVisitLogDocumentRepository waybleZoneVisitLogDocumentRepository; + + @Autowired + private RecommendLogDocumentRepository recommendLogDocumentRepository; + + @Autowired + private ObjectMapper objectMapper; + + private static final double LATITUDE = 37.495; + + private static final double LONGITUDE = 127.045; + + private static final double RADIUS = 50.0; + + private static final Long SAMPLES = 1000L; + + private static final String baseUrl = "/api/v1/wayble-zones/recommend"; + + private final Long userId = 1L; + + List nameList = new ArrayList<>(Arrays.asList( + "던킨도너츠", + "베스킨라빈스", + "투썸플레이스", + "스타벅스", + "메가엠지씨커피", + "공차", + "롯데리아", + "맥도날드", + "KFC", + "노브랜드버거" + )); + + @BeforeAll + public void setup() { + for (int i = 1; i <= SAMPLES / 2; i++) { + Long zoneId = (long) (Math.random() * SAMPLES) + 1; + if(!recommendLogDocumentRepository.existsByUserIdAndZoneId(userId, zoneId)) { + RecommendLogDocument recommendLogDocument = RecommendLogDocument + .builder() + .id(UUID.randomUUID().toString()) + .userId(userId) + .zoneId(zoneId) + .recommendationDate(makeRandomDate()) + .recommendCount(1L) + .build(); + + recommendLogDocumentRepository.save(recommendLogDocument); + } + } + + for (int i = 1; i <= SAMPLES; i++) { + Map points = makeRandomPoint(); + Address address = Address.builder() + .state("state" + i) + .city("city" + i) + .district("district" + i) + .streetAddress("street address" + i) + .detailAddress("detail address" + i) + .latitude(points.get("latitude")) + .longitude(points.get("longitude")) + .build(); + + WaybleZoneDocumentRegisterDto dto = WaybleZoneDocumentRegisterDto + .builder() + .zoneId((long) i) + .zoneName(nameList.get((int) (Math.random() * nameList.size()))) + .address(address) + .thumbnailImageUrl("thumbnail url" + i) + .waybleZoneType(WaybleZoneType.values()[i % WaybleZoneType.values().length]) + .averageRating(Math.random() * 5) + .reviewCount((long)(Math.random() * 500)) + .build(); + + WaybleZoneDocument waybleZoneDocument = WaybleZoneDocument.fromDto(dto); + waybleZoneDocumentRepository.save(waybleZoneDocument); + + User user = User.createUser( + "user" + i, + "user" + i, + "user email" + i, + "user password" + i, + generateRandomBirthDate(), + Gender.values()[i % 2], + LoginType.BASIC, + UserType.DISABLED + ); + userRepository.save(user); + + int count = (int) (Math.random() * 30) + 1; + for (int j = 0; j < count; j++) { + Long zoneId = (long) (Math.random() * SAMPLES) + 1; + WaybleZoneVisitLogDocument visitLogDocument = WaybleZoneVisitLogDocument + .builder() + .userId(user.getId()) + .zoneId(zoneId) + .ageGroup(AgeGroup.fromBirthDate(user.getBirthDate())) + .gender(user.getGender()) + .build(); + + waybleZoneVisitLogDocumentRepository.save(visitLogDocument); + } + } + } + + @AfterAll + public void teardown() { + waybleZoneDocumentRepository.deleteAll(); + waybleZoneVisitLogDocumentRepository.deleteAll(); + recommendLogDocumentRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @DisplayName("데이터 저장 테스트") + public void checkDataExists() { + List waybleZoneDocumentList = waybleZoneDocumentRepository.findAll(); + System.out.println("=== 웨이블존 목록 ==="); + + assertThat(waybleZoneDocumentList.size()).isGreaterThan(0); + for (WaybleZoneDocument doc : waybleZoneDocumentList) { + assertThat(doc.getZoneId()).isNotNull(); + assertThat(doc.getZoneName()).isNotNull(); + assertThat(doc.getAddress().getLocation()).isNotNull(); + System.out.println("존 정보: " + doc.toString()); + System.out.println("주소: " + doc.getAddress().toString()); + } + + List waybleZoneVisitLogList = waybleZoneVisitLogDocumentRepository.findAll(); + System.out.println("=== 웨이블존 방문 목록 ==="); + + assertThat(waybleZoneVisitLogList.size()).isGreaterThan(0); + for (WaybleZoneVisitLogDocument doc : waybleZoneVisitLogList) { + System.out.println("방문 정보" + doc.toString()); + } + } + + @Test + @DisplayName("추천 기록 저장 테스트") + public void saveRecommendLogTest() throws Exception { + MvcResult result = mockMvc.perform(get(baseUrl) + .param("userId", String.valueOf(userId)) + .param("latitude", String.valueOf(LATITUDE)) + .param("longitude", String.valueOf(LONGITUDE)) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + + String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = objectMapper.readTree(json); + JsonNode dataNode = root.get("data"); + + List waybleZoneRecommendResponseDtoList = objectMapper.convertValue( + dataNode, + new TypeReference<>() {} + ); + + assertThat(waybleZoneRecommendResponseDtoList.size()).isGreaterThan(0); + + WaybleZoneRecommendResponseDto dto = waybleZoneRecommendResponseDtoList.get(0); + Long zoneId = dto.zoneId(); + + Optional recommendLogDocument = recommendLogDocumentRepository.findByUserIdAndZoneId(userId, zoneId); + assertThat(recommendLogDocument.isPresent()).isTrue(); + assertThat(recommendLogDocument.get().getUserId()).isEqualTo(userId); + assertThat(recommendLogDocument.get().getZoneId()).isEqualTo(zoneId); + assertThat(recommendLogDocument.get().getRecommendationDate()).isEqualTo(LocalDate.now()); + System.out.println("===recommend log==="); + System.out.println("id = " + recommendLogDocument.get().getId()); + System.out.println("userId = " +recommendLogDocument.get().getUserId()); + System.out.println("zoneId = " +recommendLogDocument.get().getZoneId()); + System.out.println("recommendationDate = " +recommendLogDocument.get().getRecommendationDate()); + System.out.println("recommendCount " +recommendLogDocument.get().getRecommendCount()); + } + + @Test + @DisplayName("추천 기능 테스트") + public void recommendWaybleZone() throws Exception { + + MvcResult result = mockMvc.perform(get(baseUrl) + .param("userId", String.valueOf(userId)) + .param("latitude", String.valueOf(LATITUDE)) + .param("longitude", String.valueOf(LONGITUDE)) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + + String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = objectMapper.readTree(json); + JsonNode dataNode = root.get("data"); + + List WaybleZoneRecommendResponseDtoList = objectMapper.convertValue( + dataNode, + new TypeReference<>() {} + ); + + assertThat(WaybleZoneRecommendResponseDtoList.size()).isEqualTo(1); + WaybleZoneRecommendResponseDto dto = WaybleZoneRecommendResponseDtoList.get(0); + assertThat(dto.zoneId()).isNotNull(); + assertThat(dto.zoneName()).isNotNull(); + assertThat(dto.zoneType()).isNotNull(); + assertThat(dto.latitude()).isNotNull(); + assertThat(dto.longitude()).isNotNull(); + + System.out.println("zoneId = " + dto.zoneId()); + System.out.println("zoneName = " + dto.zoneName()); + System.out.println("zoneType = " + dto.zoneType()); + System.out.println("thumbnailImageUrl = " + dto.thumbnailImageUrl()); + System.out.println("latitude = " + dto.latitude()); + System.out.println("longitude = " + dto.longitude()); + System.out.println("rating = " + dto.averageRating()); + System.out.println("reviewCount = " + dto.reviewCount()); + System.out.println("distance = " + haversine(dto.latitude(), dto.longitude(), LATITUDE, LONGITUDE)); + System.out.println("distanceScore = " + dto.distanceScore()); + System.out.println("similarityScore = " + dto.similarityScore()); + System.out.println("recencyScore = " + dto.recencyScore()); + System.out.println("totalScore = " + dto.totalScore()); + } + + @Test + @DisplayName("추천 결과 상위 N개 값 테스트") + public void recommendWaybleZoneTop20() throws Exception { + MvcResult result = mockMvc.perform(get(baseUrl) + .param("userId", String.valueOf(userId)) + .param("latitude", String.valueOf(LATITUDE)) + .param("longitude", String.valueOf(LONGITUDE)) + .param("count", String.valueOf(20)) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + + String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = objectMapper.readTree(json); + JsonNode dataNode = root.get("data"); + + List waybleZoneRecommendResponseDtoList = objectMapper.convertValue( + dataNode, + new TypeReference<>() {} + ); + + assertThat(waybleZoneRecommendResponseDtoList.size()).isGreaterThan(0); + for (int i = 0; i < waybleZoneRecommendResponseDtoList.size(); i++) { + WaybleZoneRecommendResponseDto dto = waybleZoneRecommendResponseDtoList.get(i); + assertThat(dto.zoneId()).isNotNull(); + assertThat(dto.zoneName()).isNotNull(); + assertThat(dto.zoneType()).isNotNull(); + assertThat(dto.latitude()).isNotNull(); + assertThat(dto.longitude()).isNotNull(); + if (i > 0) { + assertThat(waybleZoneRecommendResponseDtoList.get(i - 1).totalScore()).isGreaterThanOrEqualTo(dto.totalScore()); + } + + System.out.println("zoneId = " + dto.zoneId()); + System.out.println("zoneName = " + dto.zoneName()); + System.out.println("zoneType = " + dto.zoneType()); + System.out.println("thumbnailImageUrl = " + dto.thumbnailImageUrl()); + System.out.println("latitude = " + dto.latitude()); + System.out.println("longitude = " + dto.longitude()); + System.out.println("rating = " + dto.averageRating()); + System.out.println("reviewCount = " + dto.reviewCount()); + System.out.println("distance = " + haversine(dto.latitude(), dto.longitude(), LATITUDE, LONGITUDE)); + System.out.println("distanceScore = " + dto.distanceScore()); + System.out.println("similarityScore = " + dto.similarityScore()); + System.out.println("recencyScore = " + dto.recencyScore()); + System.out.println("totalScore = " + dto.totalScore()); + } + } + + private double haversine(double lat1, double lon1, double lat2, double lon2) { + final int R = 6_371; // 지구 반지름 (km) + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon/2) * Math.sin(dLon/2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; + } + + private LocalDate makeRandomDate() { + Random random = new Random(); + int daysAgo = random.nextInt(40) + 1; + return LocalDate.now().minusDays(daysAgo); + } + + private Map makeRandomPoint() { + double radiusDeg = RADIUS / 111.0; + + Random rnd = new Random(); + + double u = rnd.nextDouble(); + double v = rnd.nextDouble(); + double w = radiusDeg * Math.sqrt(u); + double t = 2 * Math.PI * v; + + double latOffset = w * Math.cos(t); + double lngOffset = w * Math.sin(t) / Math.cos(Math.toRadians(LATITUDE)); + + double randomLat = LATITUDE + latOffset; + double randomLng = LONGITUDE + lngOffset; + + return Map.of("latitude", randomLat, "longitude", randomLng); + } + + private LocalDate generateRandomBirthDate() { + LocalDate today = LocalDate.now(); + LocalDate start = today.minusYears(90); // 90세 + LocalDate end = today.minusYears(10); // 10세 + + long daysBetween = ChronoUnit.DAYS.between(start, end); + long randomDays = ThreadLocalRandom.current().nextLong(daysBetween + 1); + + return start.plusDays(randomDays); + } +} diff --git a/src/test/java/com/wayble/server/search/WaybleZoneSearchApiIntegrationTest.java b/src/test/java/com/wayble/server/explore/WaybleZoneSearchApiIntegrationTest.java similarity index 99% rename from src/test/java/com/wayble/server/search/WaybleZoneSearchApiIntegrationTest.java rename to src/test/java/com/wayble/server/explore/WaybleZoneSearchApiIntegrationTest.java index 467ebada..ad299b58 100644 --- a/src/test/java/com/wayble/server/search/WaybleZoneSearchApiIntegrationTest.java +++ b/src/test/java/com/wayble/server/explore/WaybleZoneSearchApiIntegrationTest.java @@ -1,4 +1,4 @@ -package com.wayble.server.search; +package com.wayble.server.explore; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode;