diff --git a/src/main/java/com/wayble/server/explore/controller/WaybleZoneSearchController.java b/src/main/java/com/wayble/server/explore/controller/WaybleZoneSearchController.java index dfbe0eff..55c09e5d 100644 --- a/src/main/java/com/wayble/server/explore/controller/WaybleZoneSearchController.java +++ b/src/main/java/com/wayble/server/explore/controller/WaybleZoneSearchController.java @@ -67,6 +67,14 @@ public CommonResponse> findMostLikesWaybleZo )); } + @GetMapping("/validate") + public CommonResponse findIsValidWaybleZone( + @Valid @ModelAttribute WaybleZoneSearchConditionDto conditionDto + ) + { + return CommonResponse.success(waybleZoneSearchService.isValidWaybleZone(conditionDto)); + } + @PostMapping("") public CommonResponse registerDocumentFromDto(@RequestBody WaybleZoneRegisterDto registerDto) { waybleZoneDocumentService.saveDocumentFromDto(registerDto); diff --git a/src/main/java/com/wayble/server/explore/dto/search/request/WaybleZoneSearchConditionDto.java b/src/main/java/com/wayble/server/explore/dto/search/request/WaybleZoneSearchConditionDto.java index 92ff24aa..72a7d86f 100644 --- a/src/main/java/com/wayble/server/explore/dto/search/request/WaybleZoneSearchConditionDto.java +++ b/src/main/java/com/wayble/server/explore/dto/search/request/WaybleZoneSearchConditionDto.java @@ -19,7 +19,6 @@ public record WaybleZoneSearchConditionDto( @NotNull(message = "경도 입력은 필수입니다.") Double longitude, - @DecimalMin(value = "0.1", message = "검색 반경은 100미터 이상이어야 합니다.") Double radiusKm, @Size(min = 2, message = "zoneName은 최소 2글자 이상이어야 합니다.") 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 75255127..60ca3f5e 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 @@ -123,4 +123,99 @@ public Slice searchWaybleZonesByCondition(WaybleZon return new SliceImpl<>(dtos, pageable, hasNext); } + + /** + * 30m 이내이고 이름이 유사한 WaybleZone 찾기 + * @param cond 검색 조건 (위도, 경도, 이름 포함) + * @return 조건에 맞는 첫 번째 결과 또는 null + */ + public WaybleZoneSearchResponseDto findSimilarWaybleZone(WaybleZoneSearchConditionDto cond) { + if (cond.zoneName() == null || cond.zoneName().isBlank()) { + return null; + } + + // 30m 이내 검색 + Query query = Query.of(q -> q + .bool(b -> { + // 이름 유사도 검색 (fuzzy + match 조합) + b.should(s -> s + .match(m -> m + .field("zoneName") + .query(cond.zoneName()) + .boost(2.0f) // 정확한 매치에 높은 점수 + ) + ); + b.should(s -> s + .fuzzy(f -> f + .field("zoneName") + .value(cond.zoneName()) + .fuzziness("AUTO") // 오타 허용 + .boost(1.5f) + ) + ); + // 부분 매치도 포함 (공백 제거 후 검색) + String cleanedName = cond.zoneName().replaceAll("\\s+", ""); + b.should(s -> s + .wildcard(w -> w + .field("zoneName") + .value("*" + cleanedName + "*") + .boost(1.0f) + ) + ); + + // 최소 하나의 should 조건은 만족해야 함 + b.minimumShouldMatch("1"); + + // 30m 이내 필터 + b.filter(f -> f + .geoDistance(gd -> gd + .field("address.location") + .location(loc -> loc + .latlon(ll -> ll + .lat(cond.latitude()) + .lon(cond.longitude()) + ) + ) + .distance("30m") + ) + ); + return b; + }) + ); + + // 정렬: 점수 + 거리 조합 + SortOptions scoreSort = SortOptions.of(s -> s.score(sc -> sc.order(SortOrder.Desc))); + SortOptions geoSort = SortOptions.of(s -> s + .geoDistance(gds -> gds + .field("address.location") + .location(GeoLocation.of(gl -> gl + .latlon(ll -> ll + .lat(cond.latitude()) + .lon(cond.longitude()) + ) + )) + .order(SortOrder.Asc) + ) + ); + + NativeQuery nativeQuery = NativeQuery.builder() + .withQuery(query) + .withSort(scoreSort) + .withSort(geoSort) + .withPageable(PageRequest.of(0, 1)) // 첫 번째 결과만 + .build(); + + SearchHits hits = + operations.search(nativeQuery, WaybleZoneDocument.class, INDEX); + + if (hits.isEmpty()) { + return null; + } + + WaybleZoneDocument doc = hits.getSearchHit(0).getContent(); + Double distanceInMeters = (Double) hits.getSearchHit(0).getSortValues().get(1); // 거리는 두 번째 정렬값 + Double distanceInKm = distanceInMeters / 1000.0; + + return WaybleZoneSearchResponseDto.from(doc, distanceInKm); + } } \ No newline at end of file diff --git a/src/main/java/com/wayble/server/explore/service/WaybleZoneSearchService.java b/src/main/java/com/wayble/server/explore/service/WaybleZoneSearchService.java index 8179752d..43f81071 100644 --- a/src/main/java/com/wayble/server/explore/service/WaybleZoneSearchService.java +++ b/src/main/java/com/wayble/server/explore/service/WaybleZoneSearchService.java @@ -42,4 +42,8 @@ public List searchMostLikesWaybleZoneByDistrict(S return waybleZoneRepository.findTop3likesWaybleZonesByDistrict(district); } + + public WaybleZoneSearchResponseDto isValidWaybleZone(WaybleZoneSearchConditionDto condition) { + return waybleZoneQuerySearchRepository.findSimilarWaybleZone(condition); + } } diff --git a/src/test/java/com/wayble/server/explore/WaybleZoneSearchApiIntegrationTest.java b/src/test/java/com/wayble/server/explore/WaybleZoneSearchApiIntegrationTest.java index 36e5e30d..390b4fb1 100644 --- a/src/test/java/com/wayble/server/explore/WaybleZoneSearchApiIntegrationTest.java +++ b/src/test/java/com/wayble/server/explore/WaybleZoneSearchApiIntegrationTest.java @@ -530,6 +530,83 @@ public void findMostLikesWaybleZoneByDistrict() throws Exception{ } } + @Test + @DisplayName("위도, 경도, 이름 정보를 바탕으로 웨이블존이 맞는지 여부 반환 테스트") + public void findIsValidWaybleZoneTest () throws Exception{ + List waybleZoneList = waybleZoneRepository.findAll(); + WaybleZone waybleZone = waybleZoneList.get(0); + String zoneName = waybleZone.getZoneName(); + MvcResult result = mockMvc.perform(get(baseUrl + "/validate") + .header("Authorization", "Bearer " + token) + .param("latitude", String.valueOf(waybleZone.getAddress().getLatitude())) + .param("longitude", String.valueOf(waybleZone.getAddress().getLongitude())) + .param("zoneName", zoneName.substring(0, 2)) + .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"); + + System.out.println("==== 응답 결과 ===="); + System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(objectMapper.readTree(json))); + + WaybleZoneSearchResponseDto dto = + objectMapper.convertValue( + dataNode, + new TypeReference<>() {} + ); + + // 검증 로직 + assertThat(dto).isNotNull(); + + // 반환된 결과가 유효한 WaybleZoneSearchResponseDto인지 확인 + WaybleZoneInfoResponseDto infoDto = dto.waybleZoneInfo(); + assertThat(infoDto).isNotNull(); + assertThat(infoDto.zoneId()).isNotNull(); + assertThat(infoDto.zoneName()).isNotNull(); + assertThat(infoDto.zoneType()).isNotNull(); + assertThat(infoDto.latitude()).isNotNull(); + assertThat(infoDto.longitude()).isNotNull(); + + // 거리 검증 (30m 이내여야 함) + assertThat(dto.distance()) + .withFailMessage("반환된 거리(%.5f km)가 30m(0.03 km)를 초과합니다", dto.distance()) + .isLessThanOrEqualTo(0.03); + + // 이름 유사성 검증 + String requestedName = zoneName.substring(0, 2); + String foundName = infoDto.zoneName(); + assertThat(foundName) + .withFailMessage("반환된 이름(%s)이 요청한 이름(%s)과 유사하지 않습니다", foundName, requestedName) + .satisfiesAnyOf( + name -> assertThat(name).contains(requestedName), + name -> assertThat(name.replaceAll("\\s+", "")).contains(requestedName.replaceAll("\\s+", "")), + name -> assertThat(requestedName).contains(name.substring(0, Math.min(2, name.length()))) + ); + + // 정확한 거리 계산 검증 + double expectedDistance = haversine( + waybleZone.getAddress().getLatitude(), + waybleZone.getAddress().getLongitude(), + infoDto.latitude(), + infoDto.longitude() + ); + + // 허용 오차: 0.05 km (≈50m) + assertThat(dto.distance()) + .withFailMessage("계산된 거리(%.5f km)와 반환된 거리(%.5f km)가 다릅니다", + expectedDistance, dto.distance()) + .isCloseTo(expectedDistance, offset(0.05)); + + System.out.println(" 요청한 이름: " + requestedName); + System.out.println(" 찾은 이름: " + foundName); + System.out.println(" 거리: " + String.format("%.3f km", dto.distance())); + System.out.println(" 위치: " + infoDto.latitude() + ", " + infoDto.longitude()); + } + private double haversine(double lat1, double lon1, double lat2, double lon2) { final int R = 6_371; // 지구 반지름 (km) double dLat = Math.toRadians(lat2 - lat1);