diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 421cb2e..23dd5ef 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -17,8 +17,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; + import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -182,7 +183,7 @@ public ResponseEntity> getMyCourse( return ResponseEntity.ok(CommonResponse.success(SUCCESS, myCourseDetailDto)); } - @Operation(summary = "지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.") + @Operation(summary = "부산 지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공") }) @@ -291,4 +292,18 @@ public ResponseEntity imageProxy(@RequestParam String url) { } } + @Operation(summary = "대한민국 지역 판별", description = "특정 좌표 배열이 모두 대한민국 내 지역인지 판별합니다. 하나의 좌표라도 대한민국이 아닐 경우, false를 반환하며, 요청 시 좌표 배열은 시작점과 도착점을 포함하여 최소 2개 이상이어야 합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "400", description = "실패 (좌표가 2개 미만) - INVALID_INPUT_VALUE") + }) + @PostMapping("/api/locations/korea") + public ResponseEntity> isKoreaCourse( + @Valid @RequestBody CoordinateListDto coordinateDtoList + ) { + log.info("[대한민국 판별] {}개 좌표 검사 시작", coordinateDtoList.coordinateDtoList().size()); + boolean isKoreaCourse = courseService.isKoreaCourse(coordinateDtoList); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, isKoreaCourse)); + } + } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CoordinateListDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CoordinateListDto.java new file mode 100644 index 0000000..a521e75 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/CoordinateListDto.java @@ -0,0 +1,14 @@ +package com.server.running_handai.domain.course.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record CoordinateListDto( + @NotNull(message = "좌표 배열은 null일 수 없습니다.") + @Size(min = 2, message = "좌표 배열은 시작점과 도착점을 포함하여 최소 2개 이상이어야 합니다.") + List coordinateDtoList +) { + public record CoordinateDto(double latitude, double longitude) {} +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java index cd7cb02..b64c89e 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java @@ -27,13 +27,12 @@ import com.server.running_handai.domain.review.service.ReviewService; import com.server.running_handai.domain.spot.dto.SpotInfoDto; import com.server.running_handai.domain.spot.repository.SpotRepository; -import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Coordinate; @@ -55,6 +54,10 @@ public class CourseService { public static final String MYSQL_POINT_FORMAT = "POINT(%f %f)"; public static final String COURSE_NAME_DELIMITER = "-"; + private static final double SOUTH_KOREA_MIN_LATITUDE = 33.0; + private static final double SOUTH_KOREA_MAX_LATITUDE = 38.9; + private static final double SOUTH_KOREA_MIN_LONGITUDE = 124.5; + private static final double SOUTH_KOREA_MAX_LONGITUDE = 132.0; private final CourseRepository courseRepository; private final TrackPointRepository trackPointRepository; @@ -254,7 +257,7 @@ public CourseSummaryDto getCourseSummary(Long courseId, Long memberId) { */ public GpxPathDto downloadGpx(Long courseId, Long memberId) { Course course = courseRepository.findByIdAndCreatorId(courseId, memberId) - .orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); + .orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); // Presigned GET URL 발급 (1시간) String gpxPath = fileService.getPresignedGetUrl(course.getGpxPath(), 60); @@ -287,7 +290,7 @@ public MyAllCoursesDetailDto getMyAllCourses(Long memberId, Pageable pageable, S */ public MyCourseDetailDto getMyCourse(Long memberId, Long courseId) { Course course = courseRepository.findByIdAndCreatorIdWithTrackPoints(courseId, memberId) - .orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); + .orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); List trackPointDtos = course.getTrackPoints().stream() .map(TrackPointDto::from) @@ -433,4 +436,59 @@ private void updateThumbnailImage(Course course, MultipartFile newImageFile) { courseDataService.updateCourseImage(course.getId(), newImageFile); } } + + /** + * 주어진 좌표 배열의 모든 좌표가 대한민국 내에 있는지 판별합니다. + * + * @param coordinateDtoList 좌표 배열 + * @return 모두 대한민국 지역 내에 있으면 true, 하나라도 아니면 false + */ + public Boolean isKoreaCourse(CoordinateListDto coordinateDtoList) { + AtomicInteger apiCallCount = new AtomicInteger(0); + + boolean result = coordinateDtoList.coordinateDtoList().stream() + .allMatch(dto -> isCoordinateInKorea(dto, apiCallCount)); // false일 경우, 즉시 종료! + + log.info("[대한민국 판별] 경계 좌표 필터링 통과해 API 호출: {}", apiCallCount.get()); + return result; + } + + /** + * 단일 좌표가 대한민국 내에 있는지 판별합니다. + * + * @param coordinateDto 검사할 좌표 DTO + * @param apiCallCount API 호출 개수 + * @return 대한민국 내에 있으면 true, 아니면 false + */ + private boolean isCoordinateInKorea( + CoordinateListDto.CoordinateDto coordinateDto, + AtomicInteger apiCallCount + ) { + double latitude = coordinateDto.latitude(); + double longitude = coordinateDto.longitude(); + + // 대한민국 경계 좌표를 통해 필터링 + if (latitude < SOUTH_KOREA_MIN_LATITUDE || latitude > SOUTH_KOREA_MAX_LATITUDE + || longitude < SOUTH_KOREA_MIN_LONGITUDE || longitude > SOUTH_KOREA_MAX_LONGITUDE) { + return false; + } + + apiCallCount.incrementAndGet(); + + // 대한민국 경계 좌표를 통과한 좌표에 대해 카카오 지도 API를 통해 행정구역 정보를 조회 + // 행정구역 정보가 존재하지 않을 경우 false로 반환 + JsonNode regionCode = kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude); + if (regionCode == null) { + return false; + } + + // 행정구역 정보에서 시도단위 추출 + // 시도단위가 존재하지 않을 경우 false로 반환 + String provinceName = kakaoMapService.extractProvinceName(regionCode); + if (provinceName == null) { + return false; + } + + return true; + } } diff --git a/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java b/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java index c38f9ae..1d81e55 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java @@ -19,6 +19,12 @@ public class KakaoMapService { @Value("${spring.security.oauth2.client.registration.kakao.client-id}") private String kakaoApiKey; + @Value("${kakao.address}") + private String addressRequestUrl; + + @Value("${kakao.region-code}") + private String regionCodeRequestUrl; + public record AddressInfo(String districtName, String dongName) {} /** @@ -29,7 +35,7 @@ public record AddressInfo(String districtName, String dongName) {} * @return 주소 정보가 담긴 JsonNode (성공 시 documents[0]), 없거나 파싱 실패시 null */ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { - String requestUrl = "https://dapi.kakao.com/v2/local/geo/coord2address.json" + String requestUrl = addressRequestUrl + "?x=" + longitude + "&y=" + latitude + "&input_coord=WGS84"; // 좌표계 (기본값) @@ -53,6 +59,7 @@ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { // documents 안에 도로명 주소(road_address)와 지번 주소(address)가 포함되어 응답 JsonNode documents = root.path("documents"); + // 도로명 주소(road_address)는 좌표에 따라 반환되지 않을 수 있기 때문에 지번 주소(address)를 기준으로 함 if (documents.isArray() && !documents.isEmpty()) { return documents.get(0); } @@ -68,7 +75,6 @@ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { /** * 카카오 지도 API에서 가져온 주소 정보에서 구 단위, 동 단위를 추출합니다. - * 도로명 주소(road_address)는 좌표에 따라 반환되지 않을 수 있기 때문에 지번 주소(address)를 기준으로 합니다. * * @param jsonNode 주소 정보 JSON * @return districtName, dongName으로 구성된 AddressInfo, 없으면 null @@ -84,6 +90,65 @@ public AddressInfo extractDistrictNameAndDongName(JsonNode jsonNode) { return new AddressInfo(districtName, dongName); } + /** + * 주어진 위도(latitude), 경도(longitude) 좌표로부터 카카오 지도 API를 통해 행정구역 정보를 조회합니다. + * + * @param longitude 경도 (x) + * @param latitude 위도 (y) + * @return 행정구역 정보가 담긴 JsonNode (성공 시 documents[0]), 없거나 파싱 실패시 null + */ + public JsonNode getRegionCodeFromCoordinate(double longitude, double latitude) { + String requestUrl = regionCodeRequestUrl + + "?x=" + longitude + + "&y=" + latitude; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + kakaoApiKey); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange( + requestUrl, + HttpMethod.GET, + entity, + String.class + ); + + JsonNode root = objectMapper.readTree(response.getBody()); + + // documents 안에 해당 좌표에 부합하는 행정동(H), 법정동(B) 행정구역 정보가 포함되어 응답 + JsonNode documents = root.path("documents"); + + // 법으로 지정되어 행정동(H)보다 안정적인 법정동(B)을 기준으로 함 + if (documents.isArray() && !documents.isEmpty()) { + return documents.get(0); + } + + log.warn("[카카오 지도 API 호출] 카카오 지도 API에서 행정구역 정보 없음: x={}, y={}", longitude, latitude); + return null; + + } catch (Exception e) { + log.error("[카카오 지도 API 호출] 카카오 지도 API 파싱 실패: x={}, y={}", longitude, latitude, e); + return null; + } + } + + /** + * 카카오 지도 API에서 가져온 행정구역 정보에서 시도 단위를 추출합니다. + * + * @param jsonNode 행정구역 정보 JSON + * @return provinceName, 없으면 null + */ + public String extractProvinceName(JsonNode jsonNode) { + if (jsonNode == null) { + return null; + } + + return textToNull(jsonNode.path("region_1depth_name").asText()); + } + /** * 주어진 텍스트가 Null이거나 공백 문자인 경우 Null로 반환합니다. */ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9d22e89..ccf4a26 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -134,3 +134,7 @@ swagger: server: local: http://localhost:8080 prod: https://api.runninghandai.com + +kakao: + address: https://dapi.kakao.com/v2/local/geo/coord2address.json + region-code: https://dapi.kakao.com/v2/local/geo/coord2regioncode.json diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java index a489b2d..f970fea 100644 --- a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java @@ -3,11 +3,7 @@ import static com.server.running_handai.domain.course.entity.CourseFilter.*; import static com.server.running_handai.domain.course.entity.SpotStatus.*; import static com.server.running_handai.domain.course.service.CourseService.MYSQL_POINT_FORMAT; -import static com.server.running_handai.global.response.ResponseCode.COURSE_NOT_FOUND; -import static com.server.running_handai.global.response.ResponseCode.DUPLICATE_COURSE_NAME; -import static com.server.running_handai.global.response.ResponseCode.INVALID_COURSE_NAME_PARAMETER; -import static com.server.running_handai.global.response.ResponseCode.MEMBER_NOT_FOUND; -import static com.server.running_handai.global.response.ResponseCode.NO_AUTHORITY_TO_DELETE_COURSE; +import static com.server.running_handai.global.response.ResponseCode.*; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -20,6 +16,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -47,6 +44,8 @@ import java.time.LocalDateTime; import java.util.*; import java.util.stream.Stream; + +import org.assertj.core.api.AssertionsForClassTypes; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -1267,4 +1266,141 @@ void updateCourse_fail_duplicateName() { assertThat(course.getName()).isEqualTo(originalCourseName); } } + + @Nested + @DisplayName("대한민국 판별 테스트") + class CheckKoreaTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * [대한민국 판별] 성공 + * 1. 모든 좌표가 대한민국에 속하는 경우 (true 응답) + */ + @Test + @DisplayName("대한민국 판별 성공 - true 반환") + void isKoreaCourse_success_inKorea() throws Exception { + // given + List coordinateDtoList = List.of( + new CoordinateListDto.CoordinateDto(37.566826, 126.9786567), // 서울시청 + new CoordinateListDto.CoordinateDto(35.158656, 129.160113) // 해운대 해수욕장 + ); + + CoordinateListDto coordinateListDto = new CoordinateListDto(coordinateDtoList); + + JsonNode mockRegionCode1 = createMockRegionCodeNode("서울특별시"); + JsonNode mockRegionCode2 = createMockRegionCodeNode("부산광역시"); + + given(kakaoMapService.getRegionCodeFromCoordinate(coordinateDtoList.get(0).longitude(), coordinateDtoList.get(0).latitude())).willReturn(mockRegionCode1); + given(kakaoMapService.getRegionCodeFromCoordinate(coordinateDtoList.get(1).longitude(), coordinateDtoList.get(1).latitude())).willReturn(mockRegionCode2); + given(kakaoMapService.extractProvinceName(mockRegionCode1)).willReturn("서울특별시"); + given(kakaoMapService.extractProvinceName(mockRegionCode2)).willReturn("부산광역시"); + + // when + boolean result = courseService.isKoreaCourse(coordinateListDto); + + // then + assertThat(result).isTrue(); + } + + /** + * [대한민국 판별] 성공 + * 2. 모든 좌표가 대한민국에 속하는 경우 (false 응답) + */ + @ParameterizedTest + @MethodSource("coordinateProvider") + @DisplayName("대한민국 판별 성공 - false 반환") + void isKoreaCourse_success_notInKorea(CoordinateListDto coordinateListDto, + boolean[] expectedBoundaryResults, + boolean expectedFinalResult, + String[] provinceNames + ) throws Exception { + // given + for (int i = 0; i < coordinateListDto.coordinateDtoList().size(); i++) { + // 경계 좌표 검증 + CoordinateListDto.CoordinateDto coordinateDto = coordinateListDto.coordinateDtoList().get(i); + double latitude = coordinateDto.latitude(); + double longitude = coordinateDto.longitude(); + + boolean boundary = latitude >= 33.0 && latitude <= 38.9 && longitude >= 124.5 && longitude <= 132.0; + + // then + assertThat(boundary).isEqualTo(expectedBoundaryResults[i]); + + // 카카오 지도 검증 + if (boundary) { + JsonNode mockRegionCode = null; + if (provinceNames[i] != null) { + mockRegionCode = createMockRegionCodeNode(provinceNames[i]); + + // given + given(kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude)).willReturn(mockRegionCode); + given(kakaoMapService.extractProvinceName(mockRegionCode)).willReturn(mockRegionCode.get("region_1depth_name").asText()); + } else { + lenient().when(kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude)).thenReturn(null); + } + } + } + + // when + boolean result = courseService.isKoreaCourse(coordinateListDto); + + // then + assertThat(result).isFalse(); + assertThat(result).isEqualTo(expectedFinalResult); + } + + static Stream coordinateProvider() { + return Stream.of( + Arguments.of( + new CoordinateListDto(List.of( + // [CAES 1] 2개 중 1개만 해외 (경계 좌표에서 필터링) + new CoordinateListDto.CoordinateDto(33.499621, 126.531188), // 제주도 + new CoordinateListDto.CoordinateDto(48.858370, 2.294481) // 프랑스 파리 + )), + new boolean[]{true, false}, + false, + new String[]{"제주특별자치도", null} + ), + Arguments.of( + new CoordinateListDto(List.of( + // [CAES 2] 2개 1개만 해외 (경계 좌표 통과) + new CoordinateListDto.CoordinateDto(35.160032, 126.851338), // 광주 + new CoordinateListDto.CoordinateDto(34.2565, 129.2891) // 일본 대마도 + )), + new boolean[]{true, true}, + false, + new String[]{"광주광역시", null} + ), + Arguments.of( + new CoordinateListDto(List.of( + // [CAES 3] 2개 모두 해외 (경계 좌표에서 필터링) + new CoordinateListDto.CoordinateDto(40.689247, -74.044502), // 미국 뉴욕 + new CoordinateListDto.CoordinateDto(35.658581, 139.745433) // 일본 도쿄 + )), + new boolean[]{false, false}, + false, + new String[]{null, null} + ), + Arguments.of( + new CoordinateListDto(List.of( + // [CAES 4] 2개 모두 해외 (경계 좌표 통과) + new CoordinateListDto.CoordinateDto(34.2565, 129.2891), // 일본 대마도 + new CoordinateListDto.CoordinateDto(34.694406, 129.441962) // 일본 대마도 + )), + new boolean[]{true, true}, + false, + new String[]{null, null} + ) + ); + } + + private JsonNode createMockRegionCodeNode(String provinceName) throws Exception { + String jsonString = String.format( + "{\"region_1depth_name\": \"%s\"}", + provinceName + ); + + return objectMapper.readTree(jsonString); + } + } } \ No newline at end of file