Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,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;
Expand Down Expand Up @@ -182,7 +183,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 +292,18 @@ 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
) {
log.info("[대한민국 판별] {}개 좌표 검사 시작", coordinateDtoList.coordinateDtoList().size());
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,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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<TrackPointDto> trackPointDtos = course.getTrackPoints().stream()
.map(TrackPointDto::from)
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

/**
Expand All @@ -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"; // 좌표계 (기본값)
Expand All @@ -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);
}
Expand All @@ -68,7 +75,6 @@ public JsonNode getAddressFromCoordinate(double longitude, double latitude) {

/**
* 카카오 지도 API에서 가져온 주소 정보에서 구 단위, 동 단위를 추출합니다.
* 도로명 주소(road_address)는 좌표에 따라 반환되지 않을 수 있기 때문에 지번 주소(address)를 기준으로 합니다.
*
* @param jsonNode 주소 정보 JSON
* @return districtName, dongName으로 구성된 AddressInfo, 없으면 null
Expand All @@ -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<String> entity = new HttpEntity<>(headers);

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
4 changes: 4 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading