diff --git a/sql/V1_create_course_related_tables.sql b/sql/V1_create_course_related_tables.sql index 1c3caea..d675325 100644 --- a/sql/V1_create_course_related_tables.sql +++ b/sql/V1_create_course_related_tables.sql @@ -3,21 +3,21 @@ -- 1. course 테이블 생성 create table course ( - distance int not null, - duration int not null, - max_ele double not null, - min_ele double not null, + distance int not null, + duration int not null, + max_ele double not null, + min_ele double not null, course_id bigint auto_increment primary key, - created_at datetime(6) not null, - updated_at datetime(6) not null, - external_id varchar(255) null, - gpx_path varchar(255) not null, - name varchar(255) not null, - tour_point text null, - area enum ('HAEUN_GWANGAN', 'NORTHERN_BUSAN', 'SEOMYEON_DONGNAE', 'SONGJEONG_GIJANG', 'SOUTHERN_COAST', 'UNKNOWN', 'WESTERN_NAKDONGRIVER', 'WONDOSIM') not null, - level enum ('EASY', 'HARD', 'MEDIUM') not null, - start_point point not null, + created_at datetime(6) not null, + updated_at datetime(6) not null, + external_id varchar(255) null, + gpx_path varchar(255) not null, + name varchar(255) not null, + tour_point text null, + area enum ('HAEUN_GWANGAN', 'NORTHERN_BUSAN', 'SEOMYEON_DONGNAE', 'SONGJEONG_GIJANG', 'SOUTHERN_COAST', 'ETC', 'WESTERN_NAKDONGRIVER', 'WONDOSIM') not null, + level enum ('EASY', 'HARD', 'MEDIUM') not null, + start_point point not null, constraint UK4xqvdpkafb91tt3hsb67ga3fj unique (name), constraint UKftj9sywcqetdlrcts15h17nx3 @@ -44,8 +44,8 @@ create table course_image -- 3. course_themes 테이블 생성 create table course_themes ( - course_course_id bigint not null, - theme enum ('DOWNTOWN', 'MOUNTAIN', 'RIVERSIDE', 'SEA') null, + course_course_id bigint not null, + theme enum ('DOWNTOWN', 'MOUNTAIN', 'RIVERSIDE', 'SEA', 'ETC') null, constraint FKlmlrl4xgc258abdvsrfh9pvft foreign key (course_course_id) references course (course_id) ); @@ -124,7 +124,7 @@ create table spot ( description TEXT, category ENUM('NATURE', 'HISTORY', 'RECREATION', 'EXPERIENCE', 'INDUSTRIAL', 'ARCHITECTURE', 'KOREAN_FOOD', 'WESTERN_FOOD', 'JAPANESE_FOOD', 'CHINESE_FOOD', 'GLOBAL_FOOD', - 'CAFE', 'CLUB', 'UNKNOWN') NOT NULL, + 'CAFE', 'CLUB', 'ETC') NOT NULL, lat DOUBLE NOT NULL, lon DOUBLE NOT NULL, created_at DATETIME(6) NOT NULL, diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java index 42e0742..c53174d 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseDataController.java @@ -40,7 +40,7 @@ public ResponseEntity> updateCourseImage(@PathVariable Long co @PostMapping(value = "/gpx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> createCourseToGpx(@RequestPart("courseInfo") GpxCourseRequestDto gpxCourseRequestDto, - @RequestParam("courseGpxFile") MultipartFile courseGpxFile) { + @RequestPart("courseGpxFile") MultipartFile courseGpxFile) { courseDataService.createCourseToGpx(gpxCourseRequestDto, courseGpxFile); return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); } diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Area.java b/src/main/java/com/server/running_handai/domain/course/entity/Area.java index a0be30f..ad123d9 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Area.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/Area.java @@ -3,6 +3,8 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; + +import com.server.running_handai.domain.course.service.KakaoMapService; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -16,20 +18,43 @@ public enum Area { SOUTHERN_COAST("남부해안", List.of("남구")), WESTERN_NAKDONGRIVER("서부/낙동강", List.of("사상구", "강서구", "사하구")), NORTHERN_BUSAN("북부산", List.of("금정구", "북구")), - UNKNOWN("알수없음", List.of()); + ETC("기타", List.of()); private final String description; private final List subRegions; + /** + * 카카오 지도 API에서 가져온 주소 정보에서 행정구역(Area)을 결정합니다. + * + * @param addressInfo 주소 정보 Record + * @return Area, 없으면 Area.ETC + */ + public static Area fromAddress(KakaoMapService.AddressInfo addressInfo) { + // addressInfo가 null로 반환되거나 districtName이 없으면 ETC로 설정 + if (addressInfo == null || addressInfo.districtName() == null || addressInfo.districtName().isBlank()) { + return Area.ETC; + } + + // "해운대구 송정동"만 SONGJEONG_GIJANG으로 분류 + if (addressInfo.districtName().equals("해운대구") && addressInfo.dongName() != null && addressInfo.dongName().equals("송정동")) { + return Area.SONGJEONG_GIJANG; + } + + // districtName으로 Area 설정 + return Area.findBySubRegion(addressInfo.districtName()); + } + + /** * 하위 지역명(String)을 포함하는 Area enum을 찾아 반환합니다. * * @param subRegionName (ex. "해운대구", "중구") - * @return 일치하는 Area enum을 Optional로 감싸서 반환, 없으면 Optional.empty() + * @return 하위지역이 일치하는 Area, 없으면 Area.ETC */ - public static Optional findBySubRegion(String subRegionName) { + private static Area findBySubRegion(String subRegionName) { return Arrays.stream(Area.values()) .filter(area -> area.getSubRegions().contains(subRegionName)) - .findFirst(); + .findFirst() + .orElse(Area.ETC); // 매칭되는 Area 없으면 ETC로 설정 } } diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Theme.java b/src/main/java/com/server/running_handai/domain/course/entity/Theme.java index dbbfd09..2c40918 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Theme.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/Theme.java @@ -2,6 +2,8 @@ import java.util.Arrays; import java.util.List; + +import com.server.running_handai.domain.course.service.KakaoMapService; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -11,16 +13,40 @@ public enum Theme { SEA("바다", List.of("해운대구", "수영구", "기장군", "남구")), RIVERSIDE("강변", List.of("사상구", "강서구", "사하구", "금정구", "북구")), MOUNTAIN("산", List.of("부산진구", "금정구", "남구")), - DOWNTOWN("도심", List.of("부산진구", "동래구", "연제구", "중구", "동구", "서구", "영도구")); + DOWNTOWN("도심", List.of("부산진구", "동래구", "연제구", "중구", "동구", "서구", "영도구")), + ETC("기타", List.of()); private final String description; private final List subRegions; + /** + * 카카오 지도 API에서 가져온 주소 정보에서 테마(Theme)을 결정합니다. + * + * @param addressInfo 주소 정보 Record + * @return List, 없으면 List.of(Theme.ETC) + */ + public static List fromAddress(KakaoMapService.AddressInfo addressInfo) { + // addressInfo가 null로 반환되거나 districtName이 없으면 ETC로 설정 + if (addressInfo == null || addressInfo.districtName() == null || addressInfo.districtName().isBlank()) { + return List.of(Theme.ETC); + } + + // districtName으로 Theme 설정 + List themes = Theme.findBySubRegion(addressInfo.districtName()); + + // 매칭되는 Theme 없으면 ETC로 설정 + if (themes.isEmpty()) { + return List.of(Theme.ETC); + } + + return themes; + } + /** * 하위 지역명(String)을 포함하는 모든 Theme을 찾아 반환합니다. * * @param subRegionName (ex. "해운대구", "중구") - * @return 일치하는 Theme enum을 Optional로 감싸서 반환, 없으면 Optional.empty() + * @return 일치하는 Theme enum 리스트, 없으면 빈 리스트 */ public static List findBySubRegion(String subRegionName) { return Arrays.stream(Theme.values()) diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java index 893614b..81f6231 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseDataService.java @@ -19,6 +19,7 @@ import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.course.repository.RoadConditionRepository; import com.server.running_handai.domain.course.repository.TrackPointRepository; +import com.server.running_handai.domain.course.service.KakaoMapService.AddressInfo; import com.server.running_handai.global.util.TrackPointSimplificationUtil; import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; @@ -256,16 +257,8 @@ private Course parseCourse(DurunubiApiResponseDto.Item item, List tr } String districtName = sigun.split(WHITE_SPACE)[1]; // 구 단위 행정구역명 - Area area; - if (districtName.equals("해운대구")) { // 해운대구인 경우, 카카오 지도 API 사용하여 동 단위 분류 - JsonNode startAddress = kakaoMapService.getAddressFromCoordinate(startPoint.getX(), startPoint.getY()); - area = extractArea(startAddress); - } else { - area = Area.findBySubRegion(districtName).orElseThrow(() -> { - log.error("[두루누비 코스 동기화] 지역 파싱을 실패했습니다. subRegionName: {}", districtName); - return new BusinessException(AREA_NOT_FOUND); - }); - } + KakaoMapService.AddressInfo addressInfo = determineAddressInfo(districtName, startPoint); + Area area = extractArea(addressInfo); Course course = Course.builder() .externalId(externalId) @@ -280,7 +273,7 @@ private Course parseCourse(DurunubiApiResponseDto.Item item, List tr .maxElevation(maxElevation) .build(); - Theme.findBySubRegion(districtName).forEach(course::addTheme); + extractTheme(addressInfo).forEach(course::addTheme); return course; } catch (Exception e) { log.error("[두루누비 코스 동기화] API 데이터 파싱 중 예상치 못한 예외가 발생했습니다. courseIndex: {}", item.getCourseIndex(), e); @@ -288,6 +281,25 @@ private Course parseCourse(DurunubiApiResponseDto.Item item, List tr } } + /** + * 시작점 좌표와 두루누비측 행정구역명을 기반으로 주소 정보를 결정합니다. + * 두루누비의 행정구역명이 '해운대구'의 경우 카카오 API를 호출하여 동 단위 주소를 포함한 AddressInfo를 생성하고, + * 그 외의 경우 구 단위 주소만 포함한 AddressInfo를 생성합니다. + * + * @param districtName 두루누비 API로 받은 행정구역명 (구 단위) + * @param startPoint 코스의 시작점 좌표 + * @return 주소 정보를 담은 AddressInfo 객체 + */ + private KakaoMapService.AddressInfo determineAddressInfo(String districtName, Point startPoint) { + // 행정구역이 해운대구인 경우, 카카오 지도 API를 사용하여 구 단위 정보까지 생성 + if ("해운대구".equals(districtName)) { + JsonNode startAddress = kakaoMapService.getAddressFromCoordinate(startPoint.getX(), startPoint.getY()); + return kakaoMapService.extractDistrictNameAndDongName(startAddress); + } + // 그 외 지역은 districtName만 사용하여 기본 주소 정보 생성 + return new KakaoMapService.AddressInfo(districtName, null); + } + /** * 필드가 null이거나 비어있는지 검사하고, 유효하지 않은 경우 로그를 남깁니다. * @@ -482,7 +494,6 @@ public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, Multipart log.info("[GPX 코스 생성] 시작: 파일명={}, 크기={} bytes", courseGpxFile.getOriginalFilename(), courseGpxFile.getSize()); // 1. 코스 이름 조합 - // todo: 코스명 이름이 중복되는 경우 추가적인 처리 필요 String courseName = gpxCourseRequestDto.startPointName() + "-" + gpxCourseRequestDto.endPointName(); // 2. GPX 파일의 track point 파싱 @@ -511,7 +522,8 @@ public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, Multipart // 6. 시작점을 기준으로 Area 분류 (카카오 지도 API 호출) Point startPoint = extractStartPoint(trackPoints); JsonNode startAddress = kakaoMapService.getAddressFromCoordinate(startPoint.getX(), startPoint.getY()); - Area area = extractArea(startAddress); + KakaoMapService.AddressInfo startAddressInfo = kakaoMapService.extractDistrictNameAndDongName(startAddress); + Area area = extractArea(startAddressInfo); log.info("[GPX 코스 생성] Area 분류 완료: {}", area); // 7. level, road condition을 위한 OpenAI API 호출 (응답은 "|"로 구분된 6개 설명으로 이루어짐) @@ -567,8 +579,8 @@ public void createCourseToGpx(GpxCourseRequestDto gpxCourseRequestDto, Multipart .build(); // 10. Theme 설정 - String districtName = startAddress.path("address").path("region_2depth_name").asText(); // TODO 구 단위 변수명 수정 - Theme.findBySubRegion(districtName).forEach(course::addTheme); + List themes = extractTheme(startAddressInfo); + themes.forEach(course::addTheme); courseRepository.save(course); log.info("[GPX 코스 생성] Course 저장 완료: ID={}", course.getId()); @@ -734,29 +746,37 @@ private Point extractStartPoint(List trackPoints) { } /** - * 카카오 지도 API에서 가져온 주소 정보에서 행정구역(Area)을 추출합니다. - * 도로명 주소(road_address)는 좌표에 따라 반환되지 않을 수 있기 때문에 지번 주소(address)를 기준으로 합니다. + * 주소 정보에서 행정구역(Area)을 추출합니다. * - * @param jsonNode 주소 정보 JSON - * @return Area, 없으면 Area.UNKNOWN + * @param addressInfo 주소 정보 Record + * @return Area, 없으면 Area.ETC */ - private Area extractArea(JsonNode jsonNode) { - String districtName = jsonNode.path("address").path("region_2depth_name").asText(); // 구 단위 - String dongName = jsonNode.path("address").path("region_3depth_name").asText(); // 동 단위 - - // Area 설정 - Area area; - if (dongName.equals("송정동")) { - area = Area.SONGJEONG_GIJANG; - } else { - area = Area.findBySubRegion(districtName).orElseGet(() -> { - log.warn("[GPX 코스 생성] 행정구역 매칭 실패: districtName='{}', dongName='{}'. Area.UNKNOWN 반환", districtName, dongName); - return Area.UNKNOWN; - }); + private Area extractArea(KakaoMapService.AddressInfo addressInfo) { + Area area = Area.fromAddress(addressInfo); + + if (area == Area.ETC) { + log.warn("매칭되는 지역 없음. Area.ETC으로 설정: districtName={}, dongName={}", addressInfo.districtName(), addressInfo.dongName()); } + return area; } + /** + * 주소 정보에서 테마(Theme)을 추출합니다. + * + * @param addressInfo 주소 정보 Record + * @return List, 없으면 List.of(Theme.ETC) + */ + private List extractTheme(KakaoMapService.AddressInfo addressInfo) { + List themes = Theme.fromAddress(addressInfo); + + if (themes.contains(Theme.ETC)) { + log.warn("매칭되는 테마 없음. Theme.ETC으로 설정: districtName={}", addressInfo.districtName()); + } + + return themes; + } + /** * "|" 기준으로 파싱하여 리스트로 반환합니다. * 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 44dc235..c38f9ae 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 @@ -1,7 +1,5 @@ package com.server.running_handai.domain.course.service; -import com.server.running_handai.global.response.ResponseCode; -import com.server.running_handai.global.response.exception.BusinessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.*; @@ -21,12 +19,14 @@ public class KakaoMapService { @Value("${spring.security.oauth2.client.registration.kakao.client-id}") private String kakaoApiKey; + public record AddressInfo(String districtName, String dongName) {} + /** * 주어진 위도(latitude), 경도(longitude) 좌표로부터 카카오 지도 API를 통해 주소 정보를 조회합니다. * * @param longitude 경도 (x) * @param latitude 위도 (y) - * @return 주소 정보가 담긴 JsonNode (성공 시 documents[0]), 없으면 null + * @return 주소 정보가 담긴 JsonNode (성공 시 documents[0]), 없거나 파싱 실패시 null */ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { String requestUrl = "https://dapi.kakao.com/v2/local/geo/coord2address.json" @@ -40,14 +40,14 @@ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { HttpEntity entity = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange( - requestUrl, - HttpMethod.GET, - entity, - String.class - ); - try { + ResponseEntity response = restTemplate.exchange( + requestUrl, + HttpMethod.GET, + entity, + String.class + ); + JsonNode root = objectMapper.readTree(response.getBody()); // documents 안에 도로명 주소(road_address)와 지번 주소(address)가 포함되어 응답 @@ -56,12 +56,38 @@ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { 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); - throw new BusinessException(ResponseCode.ADDRESS_PARSE_FAILED); + return null; + } + } + + /** + * 카카오 지도 API에서 가져온 주소 정보에서 구 단위, 동 단위를 추출합니다. + * 도로명 주소(road_address)는 좌표에 따라 반환되지 않을 수 있기 때문에 지번 주소(address)를 기준으로 합니다. + * + * @param jsonNode 주소 정보 JSON + * @return districtName, dongName으로 구성된 AddressInfo, 없으면 null + */ + public AddressInfo extractDistrictNameAndDongName(JsonNode jsonNode) { + if (jsonNode == null) { + return new AddressInfo(null, null); } - log.warn("[카카오 지도 API 호출] 카카오 지도 API에서 주소 정보 없음: x={}, y={}", longitude, latitude); - return null; + String districtName = textToNull(jsonNode.path("address").path("region_2depth_name").asText()); + String dongName = textToNull(jsonNode.path("address").path("region_3depth_name").asText()); + + return new AddressInfo(districtName, dongName); + } + + /** + * 주어진 텍스트가 Null이거나 공백 문자인 경우 Null로 반환합니다. + */ + private String textToNull(String text) { + return (text == null || text.isBlank()) ? null : text; } } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java b/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java index 060efc4..8d83d59 100644 --- a/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java +++ b/src/main/java/com/server/running_handai/domain/spot/entity/SpotCategory.java @@ -24,7 +24,7 @@ public enum SpotCategory { GLOBAL_FOOD("이색음식점", List.of("A05020700")), CAFE("카페", List.of("A05020900")), CLUB("클럽", List.of("A05021000")), - UNKNOWN("알수없음", List.of()); + ETC("기타", List.of()); private final String description; private final List categoryNumber; diff --git a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java index 223bec3..c394831 100644 --- a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java +++ b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java @@ -246,7 +246,7 @@ private Optional createSpot(SpotApiResponseDto.Item item) { .address(item.getSpotAddress()) .description(item.getSpotDescription()) .spotCategory(SpotCategory.findByCategoryNumber(item.getSpotCategoryNumber()) - .orElse(SpotCategory.UNKNOWN)) + .orElse(SpotCategory.ETC)) .lat(Double.parseDouble(item.getSpotLatitude())) .lon(Double.parseDouble(item.getSpotLongitude())) .build(); diff --git a/src/main/java/com/server/running_handai/global/response/ResponseCode.java b/src/main/java/com/server/running_handai/global/response/ResponseCode.java index f659431..bd4fd2c 100644 --- a/src/main/java/com/server/running_handai/global/response/ResponseCode.java +++ b/src/main/java/com/server/running_handai/global/response/ResponseCode.java @@ -43,7 +43,6 @@ public enum ResponseCode { ACCESS_DENIED(FORBIDDEN, "접근 권한이 없습니다."), // NOT_FOUND (404) - AREA_NOT_FOUND(NOT_FOUND, "지원하지 않는 지역입니다."), COURSE_NOT_FOUND(NOT_FOUND, "찾을 수 없는 코스입니다."), MEMBER_NOT_FOUND(NOT_FOUND, "찾을 수 없는 사용자입니다."), REFRESH_TOKEN_NOT_FOUND(NOT_FOUND, "찾을 수 없는 리프래시 토큰입니다."), @@ -75,7 +74,6 @@ public enum ResponseCode { FILE_UPLOAD_FAILED(INTERNAL_SERVER_ERROR, "파일 업로드를 실패했습니다."), FILE_DELETE_FAILED(INTERNAL_SERVER_ERROR, "파일 삭제를 실패했습니다."), GPX_FILE_PARSE_FAILED(INTERNAL_SERVER_ERROR, "GPX 파일 파싱을 실패했습니다"), - ADDRESS_PARSE_FAILED(INTERNAL_SERVER_ERROR, "주소 파싱을 실패했습니다"), PRESIGEND_URL_FAILED(INTERNAL_SERVER_ERROR, "Presigned Url 발급을 실패했습니다."), UNSUPPORTED_FILE_TYPE(INTERNAL_SERVER_ERROR, "지원하지 않는 파일 Content Type입니다.");