Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
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 lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -182,7 +182,7 @@ public ResponseEntity<CommonResponse<MyCourseDetailDto>> getMyCourse(
return ResponseEntity.ok(CommonResponse.success(SUCCESS, myCourseDetailDto));
}

@Operation(summary = "지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.")
@Operation(summary = "부산 지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공")
})
Expand Down Expand Up @@ -291,4 +291,17 @@ public ResponseEntity<byte[]> 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<CommonResponse<Boolean>> isKoreaCourse(
@Valid @RequestBody CoordinateListDto coordinateDtoList
) {
boolean isKoreaCourse = courseService.isKoreaCourse(coordinateDtoList);
return ResponseEntity.ok(CommonResponse.success(SUCCESS, isKoreaCourse));
}

}
Original file line number Diff line number Diff line change
@@ -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<CoordinateDto> coordinateDtoList
) {
public record CoordinateDto(double latitude, double longitude) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,15 @@
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.stream.Collectors;

import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateList;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
Expand Down Expand Up @@ -254,7 +253,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);
Expand Down Expand Up @@ -287,7 +286,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<TrackPointDto> trackPointDtos = course.getTrackPoints().stream()
.map(TrackPointDto::from)
Expand Down Expand Up @@ -433,4 +432,39 @@ private void updateThumbnailImage(Course course, MultipartFile newImageFile) {
courseDataService.updateCourseImage(course.getId(), newImageFile);
}
}

/**
* 주어진 좌표 배열의 모든 좌표가 대한민국 내에 있는지 판별합니다.
*
* @param coordinateDtoList 좌표 배열
* @return 모두 대한민국 지역 내에 있으면 true, 하나라도 아니면 false
*/
public Boolean isKoreaCourse(CoordinateListDto coordinateDtoList) {
// 모든 좌표를 순회하며 검사
for (CoordinateListDto.CoordinateDto coordinateDto : coordinateDtoList.coordinateDtoList()) {
Comment thread
ssggii marked this conversation as resolved.
Outdated
double latitude = coordinateDto.latitude();
double longitude = coordinateDto.longitude();

// 대한민국 경계 좌표를 통해 필터링
if (latitude < 33.0 || latitude > 38.9 || longitude < 124.5 || longitude > 132.0) {
return false;
}
Comment thread
ssggii marked this conversation as resolved.
Outdated

// 대한민국 경계 좌표를 통과한 좌표에 대해 카카오 지도 API를 통해 행정구역 정보를 조회
// 행정구역 정보가 존재하지 않을 경우 false로 반환
JsonNode regionCode = kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude);
if (regionCode == null) {
return false;
}

// 행정구역 정보에서 시도단위 추출
// 시도단위가 존재하지 않을 경우 false로 반환
String provinceName = kakaoMapService.extractProvinceName(regionCode);
if (provinceName == null) {
return false;
}
Comment thread
ssggii marked this conversation as resolved.
Outdated
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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);
}
Expand All @@ -68,7 +69,6 @@ public JsonNode getAddressFromCoordinate(double longitude, double latitude) {

/**
* 카카오 지도 API에서 가져온 주소 정보에서 구 단위, 동 단위를 추출합니다.
* 도로명 주소(road_address)는 좌표에 따라 반환되지 않을 수 있기 때문에 지번 주소(address)를 기준으로 합니다.
*
* @param jsonNode 주소 정보 JSON
* @return districtName, dongName으로 구성된 AddressInfo, 없으면 null
Expand All @@ -84,6 +84,66 @@ 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 = "https://dapi.kakao.com/v2/local/geo/coord2regioncode.json"
+ "?x=" + longitude
+ "&y=" + latitude;
Comment thread
ssggii marked this conversation as resolved.
Outdated

HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "KakaoAK " + kakaoApiKey);
headers.setContentType(MediaType.APPLICATION_JSON);

HttpEntity<String> entity = new HttpEntity<>(headers);
log.info("[카카오 지도 API 호출] 요청 URL: {}", requestUrl);

try {
ResponseEntity<String> 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로 반환합니다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<CoordinateListDto.CoordinateDto> 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<Arguments> 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);
}
}
}