Skip to content
Merged
32 changes: 16 additions & 16 deletions sql/V1_create_course_related_tables.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public ResponseEntity<CommonResponse<?>> updateCourseImage(@PathVariable Long co

@PostMapping(value = "/gpx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<CommonResponse<?>> 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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String> 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<Area> 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로 설정
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String> subRegions;

/**
* 카카오 지도 API에서 가져온 주소 정보에서 테마(Theme)을 결정합니다.
*
* @param addressInfo 주소 정보 Record
* @return List<Theme>, 없으면 List.of(Theme.ETC)
*/
public static List<Theme> 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<Theme> 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<Theme> findBySubRegion(String subRegionName) {
return Arrays.stream(Theme.values())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -256,16 +257,8 @@ private Course parseCourse(DurunubiApiResponseDto.Item item, List<TrackPoint> 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)
Expand All @@ -280,14 +273,33 @@ private Course parseCourse(DurunubiApiResponseDto.Item item, List<TrackPoint> 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);
return null;
}
}

/**
* 시작점 좌표와 두루누비측 행정구역명을 기반으로 주소 정보를 결정합니다.
* 두루누비의 행정구역명이 '해운대구'의 경우 카카오 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이거나 비어있는지 검사하고, 유효하지 않은 경우 로그를 남깁니다.
*
Expand Down Expand Up @@ -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 파싱
Expand Down Expand Up @@ -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개 설명으로 이루어짐)
Expand Down Expand Up @@ -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<Theme> themes = extractTheme(startAddressInfo);
themes.forEach(course::addTheme);

courseRepository.save(course);
log.info("[GPX 코스 생성] Course 저장 완료: ID={}", course.getId());
Expand Down Expand Up @@ -734,29 +746,37 @@ private Point extractStartPoint(List<TrackPoint> 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<Theme>, 없으면 List.of(Theme.ETC)
*/
private List<Theme> extractTheme(KakaoMapService.AddressInfo addressInfo) {
List<Theme> themes = Theme.fromAddress(addressInfo);

if (themes.contains(Theme.ETC)) {
log.warn("매칭되는 테마 없음. Theme.ETC으로 설정: districtName={}", addressInfo.districtName());
}

return themes;
}

/**
* "|" 기준으로 파싱하여 리스트로 반환합니다.
*
Expand Down
Loading