diff --git a/src/main/java/com/wayble/server/explore/controller/WaybleFacilitySearchController.java b/src/main/java/com/wayble/server/explore/controller/WaybleFacilitySearchController.java new file mode 100644 index 00000000..077ba1c0 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/controller/WaybleFacilitySearchController.java @@ -0,0 +1,30 @@ +package com.wayble.server.explore.controller; + +import com.wayble.server.common.response.CommonResponse; +import com.wayble.server.explore.dto.facility.WaybleFacilityConditionDto; +import com.wayble.server.explore.dto.facility.WaybleFacilityResponseDto; +import com.wayble.server.explore.service.WaybleFacilityDocumentService; +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.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Validated +@RequestMapping("/api/v1/facilities/search") +public class WaybleFacilitySearchController { + private final WaybleFacilityDocumentService waybleFacilityDocumentService; + + @GetMapping("") + public CommonResponse> findNearbyFacilities( + @Valid @ModelAttribute WaybleFacilityConditionDto conditionDto + ) { + return CommonResponse.success(waybleFacilityDocumentService.findNearbyFacilityDocuments(conditionDto)); + } +} diff --git a/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityConditionDto.java b/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityConditionDto.java new file mode 100644 index 00000000..5c774574 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityConditionDto.java @@ -0,0 +1,21 @@ +package com.wayble.server.explore.dto.facility; + +import com.wayble.server.explore.entity.FacilityType; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +public record WaybleFacilityConditionDto( + @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, + + FacilityType facilityType +) { +} diff --git a/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityRegisterDto.java b/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityRegisterDto.java new file mode 100644 index 00000000..311c2fc0 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityRegisterDto.java @@ -0,0 +1,23 @@ +package com.wayble.server.explore.dto.facility; + +import com.wayble.server.explore.entity.FacilityType; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record WaybleFacilityRegisterDto ( + @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, + + FacilityType facilityType +){ +} diff --git a/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityResponseDto.java b/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityResponseDto.java new file mode 100644 index 00000000..3bbd399d --- /dev/null +++ b/src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityResponseDto.java @@ -0,0 +1,23 @@ +package com.wayble.server.explore.dto.facility; + +import com.wayble.server.explore.entity.FacilityType; +import com.wayble.server.explore.entity.WaybleFacilityDocument; +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PRIVATE) +public record WaybleFacilityResponseDto( + Double latitude, + + Double longitude, + + FacilityType facilityType +) { + public static WaybleFacilityResponseDto from(WaybleFacilityDocument facilityDocument) { + return WaybleFacilityResponseDto.builder() + .latitude(facilityDocument.getLocation().getLat()) + .longitude(facilityDocument.getLocation().getLon()) + .facilityType(facilityDocument.getFacilityType()) + .build(); + } +} diff --git a/src/main/java/com/wayble/server/explore/entity/FacilityType.java b/src/main/java/com/wayble/server/explore/entity/FacilityType.java new file mode 100644 index 00000000..c3ab5b30 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/entity/FacilityType.java @@ -0,0 +1,6 @@ +package com.wayble.server.explore.entity; + +public enum FacilityType { + ELEVATOR, + RAMP +} diff --git a/src/main/java/com/wayble/server/explore/entity/WaybleFacilityDocument.java b/src/main/java/com/wayble/server/explore/entity/WaybleFacilityDocument.java new file mode 100644 index 00000000..56dcf3b5 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/entity/WaybleFacilityDocument.java @@ -0,0 +1,27 @@ +package com.wayble.server.explore.entity; + +import com.wayble.server.direction.entity.transportation.Facility; +import com.wayble.server.explore.dto.facility.WaybleFacilityRegisterDto; +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.GeoPointField; +import org.springframework.data.elasticsearch.core.geo.GeoPoint; + +@ToString +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Document(indexName = "wayble_facility_document", createIndex = true) +public class WaybleFacilityDocument { + @Id + @Field(name = "id") + private String id; + + @GeoPointField + private GeoPoint location; + + private FacilityType facilityType; +} diff --git a/src/main/java/com/wayble/server/explore/repository/facility/WaybleFacilityDocumentRepository.java b/src/main/java/com/wayble/server/explore/repository/facility/WaybleFacilityDocumentRepository.java new file mode 100644 index 00000000..ad5af65e --- /dev/null +++ b/src/main/java/com/wayble/server/explore/repository/facility/WaybleFacilityDocumentRepository.java @@ -0,0 +1,10 @@ +package com.wayble.server.explore.repository.facility; + +import com.wayble.server.explore.entity.WaybleFacilityDocument; +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import java.util.List; + +public interface WaybleFacilityDocumentRepository extends ElasticsearchRepository { + List findAll(); +} diff --git a/src/main/java/com/wayble/server/explore/repository/facility/WaybleFacilityQuerySearchRepository.java b/src/main/java/com/wayble/server/explore/repository/facility/WaybleFacilityQuerySearchRepository.java new file mode 100644 index 00000000..bb794d46 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/repository/facility/WaybleFacilityQuerySearchRepository.java @@ -0,0 +1,101 @@ +package com.wayble.server.explore.repository.facility; + +import co.elastic.clients.elasticsearch._types.GeoLocation; +import co.elastic.clients.elasticsearch._types.SortOptions; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import com.wayble.server.explore.dto.facility.WaybleFacilityConditionDto; +import com.wayble.server.explore.dto.facility.WaybleFacilityResponseDto; +import com.wayble.server.explore.entity.WaybleFacilityDocument; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +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.util.List; + +@Repository +@RequiredArgsConstructor +public class WaybleFacilityQuerySearchRepository { + + private final ElasticsearchOperations operations; + private static final IndexCoordinates INDEX = IndexCoordinates.of("wayble_facility_document"); + private static final int LIMIT = 50; + + /** + * 위도, 경도, 시설 타입을 바탕으로 WaybleFacilityDocument를 거리순으로 N개 반환 + */ + public List findNearbyFacilitiesByType( + WaybleFacilityConditionDto condition) { + + double radius = 10.0; // 기본 반경 5km + String radiusWithUnit = radius + "km"; + + // 시설 타입에 따른 쿼리 조건 생성 + Query query = Query.of(q -> q + .bool(b -> { + // 시설 타입 조건 추가 + if (condition.facilityType() != null) { + b.must(m -> m + .term(t -> t + .field("facilityType.keyword") + .value(condition.facilityType().name()) + ) + ); + } + + // 위치 기반 필터: 중심 좌표 기준 반경 필터링 + b.filter(f -> f + .geoDistance(gd -> gd + .field("location") + .location(loc -> loc + .latlon(ll -> ll + .lat(condition.latitude()) + .lon(condition.longitude()) + ) + ) + .distance(radiusWithUnit) + ) + ); + + return b; + }) + ); + + // 거리 기준 오름차순 정렬 + SortOptions geoSort = SortOptions.of(s -> s + .geoDistance(gds -> gds + .field("location") + .location(GeoLocation.of(gl -> gl + .latlon(ll -> ll + .lat(condition.latitude()) + .lon(condition.longitude()) + ) + )) + .order(SortOrder.Asc) + ) + ); + + // Elasticsearch 쿼리 구성 + NativeQuery nativeQuery = NativeQuery.builder() + .withQuery(query) + .withSort(geoSort) + .withPageable(PageRequest.of(0, LIMIT)) + .build(); + + // 검색 수행 + SearchHits hits = + operations.search(nativeQuery, WaybleFacilityDocument.class, INDEX); + + // 결과를 Document 리스트로 반환 + return hits.stream() + .map(hit -> { + WaybleFacilityDocument doc = hit.getContent(); + return WaybleFacilityResponseDto.from(doc); + }) + .toList(); + } +} diff --git a/src/main/java/com/wayble/server/explore/service/WaybleFacilityDocumentService.java b/src/main/java/com/wayble/server/explore/service/WaybleFacilityDocumentService.java new file mode 100644 index 00000000..3d52e258 --- /dev/null +++ b/src/main/java/com/wayble/server/explore/service/WaybleFacilityDocumentService.java @@ -0,0 +1,22 @@ +package com.wayble.server.explore.service; + +import com.wayble.server.explore.dto.facility.WaybleFacilityConditionDto; +import com.wayble.server.explore.dto.facility.WaybleFacilityResponseDto; +import com.wayble.server.explore.repository.facility.WaybleFacilityQuerySearchRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class WaybleFacilityDocumentService { + + private final WaybleFacilityQuerySearchRepository waybleFacilityQuerySearchRepository; + + public List findNearbyFacilityDocuments(WaybleFacilityConditionDto dto) { + return waybleFacilityQuerySearchRepository.findNearbyFacilitiesByType(dto); + } +} diff --git a/src/test/java/com/wayble/server/explore/WaybleFacilityApiIntegrationTest.java b/src/test/java/com/wayble/server/explore/WaybleFacilityApiIntegrationTest.java new file mode 100644 index 00000000..a12691d4 --- /dev/null +++ b/src/test/java/com/wayble/server/explore/WaybleFacilityApiIntegrationTest.java @@ -0,0 +1,266 @@ +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.config.security.jwt.JwtTokenProvider; +import com.wayble.server.explore.dto.facility.WaybleFacilityResponseDto; +import com.wayble.server.explore.entity.FacilityType; +import com.wayble.server.explore.entity.WaybleFacilityDocument; +import com.wayble.server.explore.repository.facility.WaybleFacilityDocumentRepository; +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 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.data.elasticsearch.core.geo.GeoPoint; +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.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; + +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 WaybleFacilityApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private WaybleFacilityDocumentRepository waybleFacilityDocumentRepository; + + private static final double LATITUDE = 37.5435480; + + private static final double LONGITUDE = 126.9518410; + + private static final double RADIUS = 20.0; + + private static final int SAMPLES = 100; + + private static final String baseUrl = "/api/v1/facilities/search"; + + private Long userId; + + private String token; + + @BeforeAll + public void setup() { + User testUser = User.createUserWithDetails( + "testUser", "testUsername", UUID.randomUUID() + "@email", "password", + LocalDate.now(), Gender.MALE, LoginType.KAKAO, UserType.DISABLED + ); + + userRepository.save(testUser); + userId = testUser.getId(); + token = jwtTokenProvider.generateToken(userId, "ROLE_USER"); + + for(int i = 1; i <= SAMPLES; i++) { + Map points = makeRandomPoint(); + + WaybleFacilityDocument rampDocument = WaybleFacilityDocument.builder() + .id(UUID.randomUUID().toString()) + .location(new GeoPoint(points.get("latitude"), points.get("longitude"))) + .facilityType(FacilityType.RAMP) + .build(); + + WaybleFacilityDocument elevatorDocument = WaybleFacilityDocument.builder() + .id(UUID.randomUUID().toString()) + .location(new GeoPoint(points.get("latitude"), points.get("longitude"))) + .facilityType(FacilityType.ELEVATOR) + .build(); + + waybleFacilityDocumentRepository.save(rampDocument); + waybleFacilityDocumentRepository.save(elevatorDocument); + } + } + + @AfterAll + public void teardown() { + waybleFacilityDocumentRepository.deleteAll(); + userRepository.deleteById(userId); + } + + @Test + public void checkDataExists() { + List all = waybleFacilityDocumentRepository.findAll(); + assertThat(all.size()).isGreaterThan(0); + + for (WaybleFacilityDocument doc : all) { + assertThat(doc.getId()).isNotNull(); + assertThat(doc.getLocation()).isNotNull(); + assertThat(doc.getFacilityType()).isNotNull(); + System.out.println(doc); + } + } + + + @Test + @DisplayName("좌표를 전달받아 가까운 경사로 조회 테스트") + public void findNearbyRampFacilities() throws Exception { + MvcResult result = mockMvc.perform(get(baseUrl) + .header("Authorization", "Bearer " + token) + .param("latitude", String.valueOf(LATITUDE)) + .param("longitude", String.valueOf(LONGITUDE)) + .param("facilityType", FacilityType.RAMP.name()) + .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))); + + List dtoList = objectMapper.convertValue( + dataNode, + new TypeReference<>() {} + ); + + assertThat(dtoList).isNotEmpty(); + + // null 값 검증 + for (WaybleFacilityResponseDto dto : dtoList) { + assertThat(dto.latitude()).isNotNull(); + assertThat(dto.longitude()).isNotNull(); + assertThat(dto.facilityType()).isNotNull(); + assertThat(dto.facilityType()).isEqualTo(FacilityType.RAMP); + System.out.println(dto); + } + + // 거리순 정렬 검증 + if (dtoList.size() > 1) { + for (int i = 0; i < dtoList.size() - 1; i++) { + WaybleFacilityResponseDto current = dtoList.get(i); + WaybleFacilityResponseDto next = dtoList.get(i + 1); + + double currentDistance = haversine(LATITUDE, LONGITUDE, + current.latitude(), current.longitude()); + double nextDistance = haversine(LATITUDE, LONGITUDE, + next.latitude(), next.longitude()); + + assertThat(currentDistance).isLessThanOrEqualTo(nextDistance); + System.out.printf("Index %d: Distance = %.3f km%n", i, currentDistance); + } + // 마지막 요소의 거리도 출력 + WaybleFacilityResponseDto last = dtoList.get(dtoList.size() - 1); + double lastDistance = haversine(LATITUDE, LONGITUDE, + last.latitude(), last.longitude()); + System.out.printf("Index %d: Distance = %.3f km%n", dtoList.size() - 1, lastDistance); + } + } + + @Test + @DisplayName("좌표를 전달받아 가까운 엘리베이터 조회 테스트") + public void findNearbyElevatorFacilities() throws Exception { + MvcResult result = mockMvc.perform(get(baseUrl) + .header("Authorization", "Bearer " + token) + .param("latitude", String.valueOf(LATITUDE)) + .param("longitude", String.valueOf(LONGITUDE)) + .param("facilityType", FacilityType.ELEVATOR.name()) + .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))); + + List dtoList = objectMapper.convertValue( + dataNode, + new TypeReference<>() {} + ); + + assertThat(dtoList).isNotEmpty(); + + // null 값 검증 + for (WaybleFacilityResponseDto dto : dtoList) { + assertThat(dto.latitude()).isNotNull(); + assertThat(dto.longitude()).isNotNull(); + assertThat(dto.facilityType()).isNotNull(); + assertThat(dto.facilityType()).isEqualTo(FacilityType.ELEVATOR); + System.out.println(dto); + } + + // 거리순 정렬 검증 + if (dtoList.size() > 1) { + for (int i = 0; i < dtoList.size() - 1; i++) { + WaybleFacilityResponseDto current = dtoList.get(i); + WaybleFacilityResponseDto next = dtoList.get(i + 1); + + double currentDistance = haversine(LATITUDE, LONGITUDE, + current.latitude(), current.longitude()); + double nextDistance = haversine(LATITUDE, LONGITUDE, + next.latitude(), next.longitude()); + + assertThat(currentDistance).isLessThanOrEqualTo(nextDistance); + System.out.printf("Index %d: Distance = %.3f km%n", i, currentDistance); + } + // 마지막 요소의 거리도 출력 + WaybleFacilityResponseDto last = dtoList.get(dtoList.size() - 1); + double lastDistance = haversine(LATITUDE, LONGITUDE, + last.latitude(), last.longitude()); + System.out.printf("Index %d: Distance = %.3f km%n", dtoList.size() - 1, lastDistance); + } + } + + 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 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); + } +}