diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CourseTrackPointDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CourseTrackPointDto.java new file mode 100644 index 0000000..8192111 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/CourseTrackPointDto.java @@ -0,0 +1,10 @@ +package com.server.running_handai.domain.course.dto; + +public record CourseTrackPointDto( + long courseId, + double startPointLat, + double startPointLon, + double endPointLat, + double endPointLon +) { +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/course/repository/TrackPointRepository.java b/src/main/java/com/server/running_handai/domain/course/repository/TrackPointRepository.java index b7bc687..2425a1d 100644 --- a/src/main/java/com/server/running_handai/domain/course/repository/TrackPointRepository.java +++ b/src/main/java/com/server/running_handai/domain/course/repository/TrackPointRepository.java @@ -1,10 +1,12 @@ package com.server.running_handai.domain.course.repository; +import com.server.running_handai.domain.course.dto.CourseTrackPointDto; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.TrackPoint; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface TrackPointRepository extends JpaRepository { @@ -32,4 +34,23 @@ public interface TrackPointRepository extends JpaRepository { * 코스 ID 목록으로 모든 트랙포인트를 한 번에 조회 */ List findByCourseIdInOrderBySequenceAsc(List courseIds); + + /** + * DB에 저장된 모든 코스별 시작점, 도착점 조회 + */ + @Query( + value = "SELECT " + + " c.course_id AS courseId, " + + " stp.lat AS startPointLat, " + + " stp.lon AS startPointLon, " + + " etp.lat AS endPointLat, " + + " etp.lon AS endPointLon " + + "FROM course c " + + "JOIN track_point stp ON c.course_id = stp.course_id AND stp.sequence = 1 " + + "JOIN track_point etp ON c.course_id = etp.course_id AND etp.sequence = (" + + "SELECT MAX(mtp.sequence) FROM track_point mtp WHERE mtp.course_id = c.course_id" + + ")", + nativeQuery = true + ) + List findAllCourseTrackPoint(); } diff --git a/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java b/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java new file mode 100644 index 0000000..4665ec4 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java @@ -0,0 +1,54 @@ +package com.server.running_handai.domain.spot.client; + +import com.server.running_handai.domain.spot.dto.SpotSyncApiResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpotSyncApiClient { + private final WebClient webClient; + + @Value("${external.api.spot.base-url}") + private String baseUrl; + + @Value("${external.api.spot.service-key}") + private String serviceKey; + + /** + * [국문 관광정보] 관광정보 동기화 목록 조회 API를 요청합니다. + * 요청한 수정일을 기준으로 해당 날짜에 변경된 콘텐츠가 있는 경우 해당 콘텐츠의 정보를 응답합니다. + * + * @param areaCode 지역 코드 + * @return SpotSyncApiResponseDto + */ + public SpotSyncApiResponseDto fetchSpotSyncData(int areaCode, String date) { + // URL 생성 + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path("/areaBasedSyncList2") + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "runninghandai") + .queryParam("_type", "json") + .queryParam("areaCode", String.valueOf(areaCode)) + .queryParam("modifiedtime", date) + .queryParam("serviceKey", serviceKey); + + URI uri = builder.build(true).toUri(); + + // API 호출 + return webClient.get() + .uri(uri) + .retrieve() + .bodyToMono(SpotSyncApiResponseDto.class) + .block(); + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java b/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java index fe542e8..fc65f07 100644 --- a/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java +++ b/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java @@ -5,10 +5,10 @@ import com.server.running_handai.global.response.ResponseCode; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; @RestController @RequestMapping("/api/admin/courses") @@ -21,4 +21,23 @@ public ResponseEntity> updateSpots(@PathVariable Long courseId spotDataService.updateSpots(courseId); return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); } + + @PostMapping("/sync-spots/date") + public ResponseEntity> syncSpotsByDate(@RequestParam(required = false) String date) { + String targetDate = date; + if (targetDate == null) { + // 호출 기준 전날 날짜 계산 (YYYYMMDD) + targetDate = LocalDate.now() + .minusDays(1) + .format(DateTimeFormatter.ofPattern("yyyyMMdd")); + } + spotDataService.syncSpotsByDate(targetDate); + return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); + } + + @PostMapping("/sync-spots/location") + public ResponseEntity> syncSpotsByLocation() { + spotDataService.syncSpotsByLocation(); + return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); + } } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/dto/SpotExternalIdsDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotExternalIdsDto.java new file mode 100644 index 0000000..c2439f1 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotExternalIdsDto.java @@ -0,0 +1,22 @@ +package com.server.running_handai.domain.spot.dto; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +public record SpotExternalIdsDto( + Long courseId, + String externalIds +) { + public Set getSpotExternalIds() { + if (externalIds == null || externalIds.isEmpty()) { + return Collections.emptySet(); + } + + return Arrays.stream(externalIds.split(",")) + .map(String::trim) + .filter(externalId -> !externalId.isEmpty()) + .collect(Collectors.toSet()); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/dto/SpotSyncApiResponseDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotSyncApiResponseDto.java new file mode 100644 index 0000000..4773ab6 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotSyncApiResponseDto.java @@ -0,0 +1,82 @@ +package com.server.running_handai.domain.spot.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.List; + +@Hidden +@Getter +@ToString +@NoArgsConstructor +public class SpotSyncApiResponseDto { + @JsonProperty("response") + private SpotSyncApiResponseDto.Response response; + + @Getter + @ToString + @NoArgsConstructor + public static class Response { + @JsonProperty("header") + private SpotSyncApiResponseDto.Header header; + + @JsonProperty("body") + private SpotSyncApiResponseDto.Body body; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Header { + @JsonProperty("resultCode") + private String resultCode; + + @JsonProperty("resultMsg") + private String resultMsg; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Body { + @JsonProperty("items") + private SpotSyncApiResponseDto.Items items; + + @JsonProperty("numOfRows") + private int numOfRows; + + @JsonProperty("pageNo") + private int pageNo; + + @JsonProperty("totalCount") + private int totalCount; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Items { + @JsonProperty("item") + private List itemList; + } + + /** + * [국문 관광정보] 관광정보 동기화 목록 조회 + */ + @Getter + @ToString + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Item { + + @JsonProperty("contentid") + private String spotExternalId; // 장소 고유번호 (Spot.externalId) + + @JsonProperty("showflag") + private String spotShowflag; // 콘텐츠 표출여부 (1: 표출, 0: 비표출) + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java index afc4985..6f2e0b8 100644 --- a/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java +++ b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java @@ -63,6 +63,44 @@ public Spot(String externalId, String name, String address, String description, this.lon = lon; } + /** + * API 데이터와 비교하여 Spot 엔티티의 필드를 업데이트합니다. + * 변경이 발생했을 경우에만 true를 반환합니다. + * + * @param source 비교 대상이 되는, API 응답으로부터 변환된 Spot 객체 + * @return 내용 변경이 있었는지 여부 + */ + public boolean syncWith(Spot source) { + boolean isUpdated = false; + + if (!this.name.equals(source.getName())) { + this.name = source.getName(); + isUpdated = true; + } + if (!this.address.equals(source.getAddress())) { + this.address = source.getAddress(); + isUpdated = true; + } + if (!this.description.equals(source.getDescription())) { + this.description = source.getDescription(); + isUpdated = true; + } + if (this.spotCategory != source.getSpotCategory()) { + this.spotCategory = source.getSpotCategory(); + isUpdated = true; + } + if (this.lat != source.getLat()) { + this.lat = source.getLat(); + isUpdated = true; + } + if (this.lon != source.getLon()) { + this.lon = source.getLon(); + isUpdated = true; + } + + return isUpdated; + } + // ==== 연관관계 편의 메서드 ==== // public void setSpotImage(SpotImage spotImage) { this.spotImage = spotImage; diff --git a/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java b/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java index 57a2a39..cf7e537 100644 --- a/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java +++ b/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java @@ -6,8 +6,20 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Set; + public interface CourseSpotRepository extends JpaRepository { + /** + * 특정 코스에 연결된 모든 장소의 연관관계를 모두 삭제합니다. + */ @Modifying @Query("DELETE FROM CourseSpot cs WHERE cs.course.id = :courseId") void deleteByCourseId(@Param("courseId") Long courseId); + + /** + * 특정 코스에 연결된 장소 중 External Id의 목록에 포함된 장소의 연관관계만 삭제합니다. + */ + @Modifying + @Query("DELETE FROM CourseSpot cs WHERE cs.course.id = :courseId AND cs.spot.externalId IN :externalIds") + void deleteByCourseIdAndSpotExternalIdIn(@Param("courseId") Long courseId, @Param("externalIds") Set externalIds); } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java b/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java index d4cea41..38bfc11 100644 --- a/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java +++ b/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java @@ -1,5 +1,6 @@ package com.server.running_handai.domain.spot.repository; +import com.server.running_handai.domain.spot.dto.SpotExternalIdsDto; import com.server.running_handai.domain.spot.dto.SpotInfoDto; import com.server.running_handai.domain.spot.entity.Spot; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,6 +9,7 @@ import java.util.Collection; import java.util.List; +import java.util.Set; public interface SpotRepository extends JpaRepository { /** @@ -15,6 +17,17 @@ public interface SpotRepository extends JpaRepository { */ List findByExternalIdIn(Collection externalIds); + /** + * ExternalId Set에서 DB에 존재하는 ExternalId만 반환합니다. + */ + @Query("SELECT s.externalId FROM Spot s WHERE s.externalId IN :externalIds") + Set findExistingExternalIds(Collection externalIds); + + /** + * ExternalId로 Spot을 조회합니다. + */ + Spot findByExternalId(String externalId); + /** * CourseId와 일치하는 Spot을 SpotImage와 함께 가져옵니다. */ @@ -28,16 +41,38 @@ public interface SpotRepository extends JpaRepository { /** * CourseId와 일치하는 Spot을 SpotImage와 함께 랜덤으로 3개 가져옵니다. */ - @Query(value = "SELECT " + - " s.spot_id AS spotId, " + - " s.name, " + - " s.description, " + - " si.img_url As imageUrl " + - "FROM spot s " + - "LEFT JOIN spot_image si ON s.spot_id = si.spot_id " + - "JOIN course_spot cs ON cs.spot_id = s.spot_id " + - "WHERE cs.course_id = :courseId " + - "ORDER BY RAND() LIMIT 3", - nativeQuery = true) + @Query( + value = "SELECT " + + " s.spot_id AS spotId, " + + " s.name, " + + " s.description, " + + " si.img_url As imageUrl " + + "FROM spot s " + + "LEFT JOIN spot_image si ON s.spot_id = si.spot_id " + + "JOIN course_spot cs ON cs.spot_id = s.spot_id " + + "WHERE cs.course_id = :courseId " + + "ORDER BY RAND() LIMIT 3", + nativeQuery = true + ) List findRandom3ByCourseId(@Param("courseId") Long courseId); + + /** + * DB에 저장된 모든 Course의 ExternalId를 조회합니다. + */ + @Query( + value = "SELECT " + + " cs.course_id AS courseId, " + + " GROUP_CONCAT(s.external_id) AS externalIds " + + "FROM course_spot cs " + + "JOIN spot s ON cs.spot_id = s.spot_id " + + "GROUP BY cs.course_id", + nativeQuery = true + ) + List findAllCourseExternalIds(); + + /** + * Course와 연관관계가 없는 Spot을 조회합니다. + */ + @Query("SELECT s FROM Spot s LEFT JOIN CourseSpot cs ON s.id = cs.spot.id WHERE cs.id IS NULL") + List findSpotsWithoutCourses(); } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java b/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java new file mode 100644 index 0000000..b8a37f0 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java @@ -0,0 +1,52 @@ +package com.server.running_handai.domain.spot.scheduler; + +import com.server.running_handai.domain.spot.service.SpotDataService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpotScheduler { + private final SpotDataService spotDataService; + + /** + * 매주 월요일 새벽 4시에 즐길거리 위치 정보 동기화 작업을 실행합니다. + * cron = "[초] [분] [시] [일] [월] [요일]" + */ + @Scheduled(cron = "0 0 4 * * 1", zone = "Asia/Seoul") + public void scheduleSyncSpotsByLocation() { + log.info("[스케줄러] 즐길거리 위치 정보 동기화 작업을 시작합니다."); + try { + spotDataService.syncSpotsByLocation(); + log.info("[스케줄러] 즐길거리 위치 정보 동기화 작업을 성공적으로 완료했습니다."); + } catch (Exception e) { + log.error("[스케줄러] 즐길거리 위치 정보 동기화 작업 중 오류가 발생했습니다.", e); + } + } + + /** + * 매일 새벽 5시 30분에 즐길거리 장소 정보 동기화 작업을 실행합니다. + * cron = "[초] [분] [시] [일] [월] [요일]" + */ + @Scheduled(cron = "0 30 5 * * *", zone = "Asia/Seoul") + public void scheduleSyncSpotsByDate() { + log.info("[스케줄러] 즐길거리 장소 정보 동기화 작업을 시작합니다."); + try { + // 호출 기준 전날 날짜 계산 (YYYYMMDD) + String date = LocalDate.now() + .minusDays(1) + .format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + spotDataService.syncSpotsByDate(date); + log.info("[스케줄러] 즐길거리 장소 정보 동기화 작업을 성공적으로 완료했습니다."); + } catch (Exception e) { + log.error("[스케줄러] 즐길거리 장소 정보 동기화 작업 중 오류가 발생했습니다.", e); + } + } +} 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..c3b0b17 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 @@ -1,5 +1,6 @@ package com.server.running_handai.domain.spot.service; +import com.server.running_handai.domain.course.dto.CourseTrackPointDto; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.TrackPoint; import com.server.running_handai.domain.course.repository.CourseRepository; @@ -7,8 +8,11 @@ import com.server.running_handai.domain.course.service.FileService; import com.server.running_handai.domain.spot.client.SpotApiClient; import com.server.running_handai.domain.spot.client.SpotLocationApiClient; +import com.server.running_handai.domain.spot.client.SpotSyncApiClient; import com.server.running_handai.domain.spot.dto.SpotApiResponseDto; +import com.server.running_handai.domain.spot.dto.SpotExternalIdsDto; import com.server.running_handai.domain.spot.dto.SpotLocationApiResponseDto; +import com.server.running_handai.domain.spot.dto.SpotSyncApiResponseDto; import com.server.running_handai.domain.spot.entity.SpotCategory; import com.server.running_handai.domain.spot.entity.CourseSpot; import com.server.running_handai.domain.spot.entity.Spot; @@ -32,6 +36,7 @@ public class SpotDataService { private final SpotLocationApiClient spotLocationApiClient; private final SpotApiClient spotApiClient; + private final SpotSyncApiClient spotSyncApiClient; private final CourseRepository courseRepository; private final TrackPointRepository trackPointRepository; private final SpotRepository spotRepository; @@ -42,8 +47,11 @@ public class SpotDataService { private static final int TOURIST_SPOT_TYPE = 12; private static final int RESTAURANT_TYPE = 39; + // [국문 관광정보] 지역 코드 + private static final int BUSAN_AREA_CODE = 6; + /** - * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 API와 공통정보조회 API를 통해 가져옵니다. + * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 조회 API와 공통정보 조회 API를 통해 가져옵니다. * * @param courseId 코스 id */ @@ -63,6 +71,7 @@ public void updateSpots(Long courseId) { // 이미 externalId에 해당하는 Spot 정보가 있을 경우, 해당 정보를 가져옴 List existingSpots = spotRepository.findByExternalIdIn(externalIds); List spots = new ArrayList<>(existingSpots); + List newSpots; Set existingIds = existingSpots.stream() .map(Spot::getExternalId) @@ -73,91 +82,215 @@ public void updateSpots(Long courseId) { List items = fetchSpotsInParallel(externalIds); // Spot, SpotImage 객체 생성 - for (SpotApiResponseDto.Item item : items) { - Optional spotOptional = createSpot(item); - spotOptional.ifPresent(spot -> { - SpotImage spotImage = createSpotImage(item); - if (spotImage != null) { - spot.setSpotImage(spotImage); - } - spots.add(spot); - }); - } + newSpots = createSpots(items); + spots.addAll(newSpots); // 3. Course와 Spot의 연관관계 초기화 courseSpotRepository.deleteByCourseId(courseId); log.info("[즐길거리 수정] 기존 즐길거리 데이터 삭제 완료: courseId={}", courseId); // 4. Spot, CourseSpot DB 저장 - List newSpots = spotRepository.saveAll(spots); + spotRepository.saveAll(newSpots); - List courseSpots = newSpots.stream() + List courseSpots = spots.stream() .map(spot -> CourseSpot.builder().course(course).spot(spot).build()) .collect(Collectors.toList()); courseSpotRepository.saveAll(courseSpots); - log.info("[즐길거리 수정] DB에 즐길거리 정보 갱신 완료: courseId={}, 개수={}", courseId, spots.size()); + log.info("[즐길거리 수정] DB에 즐길거리 정보 갱신 완료: courseId={}, 새로운 장소={}, 기존 장소={}", courseId, newSpots.size(), existingSpots.size()); } /** - * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. - * API 응답값의 spotExternalId가 유효하지 않을 경우, Set에 포함하지 않습니다. + * DB에 저장된 즐길거리 정보를 [국문 관광정보]의 관광정보 동기화 목록 조회 API 호출을 통해 동기화합니다. + * 변경된 정보가 있을 경우, 공통정보 조회 API를 호출하여 DB와 비교한 후 업데이트합니다. * - * @param lon 경도 (x) - * @param lat 위도 (y) - * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) - * @return 유효한 externalId의 Set + * @param date 국문 관광정보 API에서 정보가 수정된 날짜 (YYYYMMDD) */ - private Set fetchSpotsByLocation(double lon, double lat, int contentTypeId) { - SpotLocationApiResponseDto spotLocationApiResponseDto = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, contentTypeId); + @Transactional + public void syncSpotsByDate(String date) { + // 1. 변경된 장소 정보 수집 + List modifiedItems = fetchSpotsSyncByDate(date); - if (spotLocationApiResponseDto == null || spotLocationApiResponseDto.getResponse() == null || - spotLocationApiResponseDto.getResponse().getBody() == null || spotLocationApiResponseDto.getResponse().getBody().getItems() == null) { - return Collections.emptySet(); + if (modifiedItems.isEmpty()) { + log.info("[즐길거리 장소 정보 동기화] {} | 변경된 장소 데이터가 없습니다.", date); + return; } - List items = spotLocationApiResponseDto.getResponse().getBody().getItems().getItemList(); + // 2. 현재 DB에 있는 External Id만 필터링 + Set modifiedExternalIds = modifiedItems.stream() + .map(SpotSyncApiResponseDto.Item::getSpotExternalId) + .collect(Collectors.toSet()); + + Set existingExternalIds = spotRepository.findExistingExternalIds(modifiedExternalIds); - if (items == null || items.isEmpty()) { - return Collections.emptySet(); + if (existingExternalIds.isEmpty()) { + log.info("[즐길거리 장소 정보 동기화] {} | DB에 존재하는 장소 데이터 변경 사항이 없어 종료합니다.", date); + return; } - return items.stream() - .filter(item -> isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) - .map(SpotLocationApiResponseDto.Item::getSpotExternalId) - .collect(Collectors.toSet()); + log.info("[즐길거리 장소 정보 동기화] {} | 변경된 장소 중 DB에 저장된 장소: {}개", date, existingExternalIds.size()); + + // 3. showflag별로 분류 + // 표출(1)일 경우 업데이트하고, 비표출(0)일 경우 DB에서 삭제 + Map> partitioned = modifiedItems.stream() + .filter(item -> existingExternalIds.contains(item.getSpotExternalId())) + .collect(Collectors.partitioningBy( + item -> "1".equals(item.getSpotShowflag()), // true이면 toUpdate, false이면 toDelete + Collectors.mapping(SpotSyncApiResponseDto.Item::getSpotExternalId, Collectors.toSet()) + )); + + Set toUpdate = partitioned.get(true); + Set toDelete = partitioned.get(false); + + // 4. DB 삭제 혹은 업데이트 + List updatedSpots = new ArrayList<>(); + int deletedSpotsCount = deleteSpots(toDelete); + + if (!toUpdate.isEmpty()) { + // 공통정보 조회 API 호출하여 최신 정보 가져오기 + List items = fetchSpotsInParallel(toUpdate); + updatedSpots = updateSpots(items); + } + + log.info("[즐길거리 장소 정보 동기화] {} | 완료: 업데이트 대상={}, 삭제 대상={}, 실제 업데이트={}, 실제 삭제={}", date, toUpdate.size(), toDelete.size(), updatedSpots.size(), deletedSpotsCount); } /** - * [국문 관광정보] 공통정보 조회 API를 요청해 externalId에 대한 SpotApiResponseDto.Item을 반환합니다. + * DB에 저장된 코스 시작점, 출발점에 따른 모든 Course의 Spot 정보를 [국문 관광정보]의 위치기반 관광정보 조회 API 호출을 통해 동기화합니다. + */ + @Transactional + public void syncSpotsByLocation() { + // 1. DB에 코스별 저장된 Spot의 ExternalId 조회 + Map> storedExternalIds = spotRepository.findAllCourseExternalIds().stream() + .collect(Collectors.toMap( + SpotExternalIdsDto::courseId, + SpotExternalIdsDto::getSpotExternalIds + )); + + // 2. 위치기반 관광정보 조회 API를 호출하여 코스별 새로운 External Id 수집 + Map> fetchExternalIds = fetchSpotsByLocationAllCourseInParallel(); + + // 3. 전체 코스를 기준으로 DB와 위치기반 관광정보 조회 API 데이터를 비교하여 변경사항 동기화 + fetchExternalIds.forEach((courseId, newExternalIds) -> { + Set oldExternalIds = storedExternalIds.getOrDefault(courseId, Collections.emptySet()); + + // 3-1. 변경이 없는 경우 다음 코스로 넘어감 + if (oldExternalIds.equals(newExternalIds)) { + log.debug("[즐길거리 위치 정보 동기화] DB에 존재하는 장소 데이터 변경 사항이 없어 종료합니다: courseId={}", courseId); + return; + } + + log.info("[즐길거리 위치 정보 동기화] 새로운 externalId 발견: courseId={}", courseId); + Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); + + // 3-2. 추가해야 할 External Id 수집 + Set toAdd = new HashSet<>(newExternalIds); + toAdd.removeAll(oldExternalIds); + + // 3-3. 삭제해야 할 External Id 수집 + Set toDelete = new HashSet<>(oldExternalIds); + toDelete.removeAll(newExternalIds); + + // 4. DB 삭제 혹은 업데이트 + // 4-1. 삭제해야 할 External Id Course-Spot 연관관계 삭제 + if (!toDelete.isEmpty()) { + courseSpotRepository.deleteByCourseIdAndSpotExternalIdIn(courseId, toDelete); + log.info("[즐길거리 위치 정보 동기화] {}개 Spot Course와 연결 삭제!: courseId={}", toDelete.size(), courseId); + } + + // 4-2. 추가해야 할 External Id 추가 + if (!toAdd.isEmpty()) { + // DB에 이미 존재하는 Spot인지 확인 + List existingSpots = spotRepository.findByExternalIdIn(toAdd); + Set existingExternalIds = existingSpots.stream() + .map(Spot::getExternalId) + .collect(Collectors.toSet()); + log.debug("[즐길거리 위치 정보 동기화] 기존과 겹치는 {}개의 Spot 존재: courseId={}", existingSpots.size(), courseId); + + // 기존에 없는 Spot은 새로 공통정보 조회 API를 호출하여 추가해야 함 + Set toCreate = new HashSet<>(toAdd); + toCreate.removeAll(existingExternalIds); + + // 기존에 있는 Spot은 Course-Spot 연관관계만 추가해야 함 + List toConnect = new ArrayList<>(existingSpots); + + if (!toCreate.isEmpty()) { + log.debug("[즐길거리 위치 정보 동기화] {}개 Spot 생성 필요: courseId={}", toCreate.size(), courseId); + List items = fetchSpotsInParallel(toCreate); + List newSpots = createSpots(items); + spotRepository.saveAll(newSpots); + toConnect.addAll(newSpots); + } + + List newCourseSpots = toConnect.stream() + .map(spot -> CourseSpot.builder().course(course).spot(spot).build()) + .toList(); + + courseSpotRepository.saveAll(newCourseSpots); + log.info("[즐길거리 위치 정보 동기화] {}개 Spot Course와 연결 추가!: courseId={}", newCourseSpots.size(), courseId); + } + }); + + int orphanedSpotsCount = cleanOrphanedSpots(); + log.info("[즐길거리 위치 정보 동기화] Course와 연결 없는 Spot {}개 삭제", orphanedSpotsCount); + } + + /** + * Course와 연결이 하나도 없는 Spot을 모두 삭제합니다. * - * @param externalId 장소 고유번호 - * @return SpotApiResponseDto.Item 정보, 없으면 Optional.empty() + * @return 삭제된 Spot 개수 */ - public Optional fetchSpot(String externalId) { - SpotApiResponseDto spotApiResponseDto = spotApiClient.fetchSpotData(externalId); + private int cleanOrphanedSpots() { + List orphanedSpots = spotRepository.findSpotsWithoutCourses(); - if (spotApiResponseDto == null || spotApiResponseDto.getResponse() == null || - spotApiResponseDto.getResponse().getBody() == null || spotApiResponseDto.getResponse().getBody().getItems() == null) { - return Optional.empty(); + if (!orphanedSpots.isEmpty()) { + spotRepository.deleteAll(orphanedSpots); } - List items = spotApiResponseDto.getResponse().getBody().getItems().getItemList(); + return orphanedSpots.size(); + } - if (items == null || items.isEmpty()) { - return Optional.empty(); - } + /** + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. + * API 응답값의 spotExternalId가 유효하지 않을 경우, Set에 포함하지 않습니다. + * + * @param lon 경도 (x) + * @param lat 위도 (y) + * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) + * @return externalId Set + */ + private Set fetchSpotsByLocation(double lon, double lat, int contentTypeId) { + try { + SpotLocationApiResponseDto spotLocationApiResponseDto = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, contentTypeId); + + if (spotLocationApiResponseDto == null || spotLocationApiResponseDto.getResponse() == null || + spotLocationApiResponseDto.getResponse().getBody() == null || spotLocationApiResponseDto.getResponse().getBody().getItems() == null) { + return Collections.emptySet(); + } + + List items = spotLocationApiResponseDto.getResponse().getBody().getItems().getItemList(); + + if (items == null || items.isEmpty()) { + return Collections.emptySet(); + } - return Optional.of(items.getFirst()); + return items.stream() + .filter(item -> isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) + .map(SpotLocationApiResponseDto.Item::getSpotExternalId) + .collect(Collectors.toSet()); + } catch (Exception e) { + log.warn("위치기반 관광정보 조회 API 응답 파싱 오류: {}", e.getMessage()); + return Collections.emptySet(); + } } /** - * [국문 관광정보] 위치기반 관광정보 조회 API를 요청을 병렬로 요청해 4가지 조건(시작점, 도착점, 관광지, 음식점)의 externalId를 수집합니다. + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청을 병렬로 요청해 4가지 조건(시작점, 도착점, 관광지, 음식점)에 해당하는 externalId를 수집합니다. * * @param startPoint 시작점 * @param endPoint 도착점 - * @return 수집한 중복 없는 externalId Set + * @return externalId Set */ private Set fetchSpotsByLocationInParallel(TrackPoint startPoint, TrackPoint endPoint) { List>> tasks = List.of( @@ -173,7 +306,7 @@ private Set fetchSpotsByLocationInParallel(TrackPoint startPoint, TrackP try { return task.call(); } catch (Exception e) { - log.error("[즐길거리 수정] 위치기반 관광정보 조회 API 호출 중 오류 발생: error={}", e.getMessage()); + log.error("위치기반 관광정보 조회 API 호출 중 오류 발생: error={}", e.getMessage()); return Collections.emptyList(); } }) @@ -181,6 +314,59 @@ private Set fetchSpotsByLocationInParallel(TrackPoint startPoint, TrackP .collect(Collectors.toSet()); } + /** + * DB에 저장된 모든 Course에 대해 [국문 관광정보] 위치기반 관광정보 조회 API를 병렬로 호출하여 + * 각 코스마다 4가지 조건(시작점, 도착점, 관광지, 음식점)에 해당하는 externalId를 수집합니다. + * + * @return externalId Map + */ + private Map> fetchSpotsByLocationAllCourseInParallel() { + List courseTrackPoints = trackPointRepository.findAllCourseTrackPoint(); + return courseTrackPoints.parallelStream() + .collect(Collectors.toConcurrentMap( + CourseTrackPointDto::courseId, + + courseTrackPointDto -> { + Set externalIds = new HashSet<>(); + + externalIds.addAll(fetchSpotsByLocation(courseTrackPointDto.startPointLon(), courseTrackPointDto.startPointLat(), TOURIST_SPOT_TYPE)); + externalIds.addAll(fetchSpotsByLocation(courseTrackPointDto.endPointLon(), courseTrackPointDto.endPointLat(), TOURIST_SPOT_TYPE)); + externalIds.addAll(fetchSpotsByLocation(courseTrackPointDto.startPointLon(), courseTrackPointDto.startPointLat(), RESTAURANT_TYPE)); + externalIds.addAll(fetchSpotsByLocation(courseTrackPointDto.endPointLon(), courseTrackPointDto.endPointLat(), RESTAURANT_TYPE)); + + return externalIds; + } + )); + } + + /** + * [국문 관광정보] 공통정보 조회 API를 요청해 externalId에 대한 SpotApiResponseDto.Item을 반환합니다. + * + * @param externalId 장소 고유번호 + * @return SpotApiResponseDto.Item 정보, 없으면 Optional.empty() + */ + private Optional fetchSpot(String externalId) { + try { + SpotApiResponseDto spotApiResponseDto = spotApiClient.fetchSpotData(externalId); + + if (spotApiResponseDto == null || spotApiResponseDto.getResponse() == null || + spotApiResponseDto.getResponse().getBody() == null || spotApiResponseDto.getResponse().getBody().getItems() == null) { + return Optional.empty(); + } + + List items = spotApiResponseDto.getResponse().getBody().getItems().getItemList(); + + if (items == null || items.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(items.getFirst()); + } catch (Exception e){ + log.warn("공통정보 조회 API 응답 파싱 오류: {}", e.getMessage()); + return Optional.empty(); + } + } + /** * [국문 관광정보] 공통정보 조회 API를 요청을 병렬로 요청해 externalId의 관광 정보를 수집합니다. * @@ -193,7 +379,7 @@ private List fetchSpotsInParallel(Set externalI try { return fetchSpot(externalId); } catch (Exception e) { - log.error("[즐길거리 수정] 공통정보 조회 API 호출 중 오류 발생: externalId={}, error={}", externalId, e.getMessage()); + log.error("공통정보 조회 API 호출 중 오류 발생: externalId={}, error={}", externalId, e.getMessage()); return Optional.empty(); } }) @@ -202,6 +388,37 @@ private List fetchSpotsInParallel(Set externalI .collect(Collectors.toList()); } + /** + * [국문 관광정보] 관광정보 동기화 목록 조회 API를 요청해 변경된 데이터의 SpotSyncApiResponseDto.Item을 수집합니다. + * + * @param date 국문 관광정보 API에서 정보가 수정된 날짜 (YYYYMMDD) + * @return SpotSyncApiResponseDto.Item List + */ + private List fetchSpotsSyncByDate(String date) { + try { + SpotSyncApiResponseDto spotSyncApiResponseDto = spotSyncApiClient.fetchSpotSyncData(BUSAN_AREA_CODE, date); + + if (spotSyncApiResponseDto == null || spotSyncApiResponseDto.getResponse() == null || + spotSyncApiResponseDto.getResponse().getBody() == null || spotSyncApiResponseDto.getResponse().getBody().getItems() == null) { + return Collections.emptyList(); + } + + List items = spotSyncApiResponseDto.getResponse().getBody().getItems().getItemList(); + + if (items == null || items.isEmpty()) { + return Collections.emptyList(); + } + + return items.stream() + .filter(item -> isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) + .filter(item -> isFieldValid(item.getSpotShowflag(), "spotShowflag", null)) + .collect(Collectors.toList()); + } catch (Exception e) { + log.warn("관광정보 동기화 목록 조회 API 응답 파싱 오류: {}", e.getMessage()); + return Collections.emptyList(); + } + } + /** * Spot 객체를 생성합니다. * 이때 SpotApiResponse,Item의 필드를 검사하여 유효할 경우에만 Spot을 생성합니다. @@ -254,12 +471,35 @@ private Optional createSpot(SpotApiResponseDto.Item item) { return Optional.of(spot); } + /** + * Spot 객체들을 생성합니다. + * + * @param items 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 Spot List + */ + private List createSpots(List items) { + List newSpots = new ArrayList<>(); + + for (SpotApiResponseDto.Item item : items) { + Optional spotOptional = createSpot(item); + spotOptional.ifPresent(spot -> { + SpotImage spotImage = createSpotImage(item); + if (spotImage != null) { + spot.setSpotImage(spotImage); + } + newSpots.add(spot); + }); + } + + return newSpots; + } + /** * SpotImage 객체를 생성합니다. * originalImage를 우선 저장하고, originalImage이 없는 경우 thumbnailImage를 저장합니다. * - * @param item 공통정보 조회 API로부터 받은 응답 - * @return 새로 생성된 SpotImage 객체, 없으면 null + * @param item 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 SpotImage 객체, 없으면 null */ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { String originalImage = item.getSpotOriginalImage(); @@ -278,9 +518,80 @@ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { .originalUrl(thumbnailImage) .build(); } + return null; } + /** + * API 데이터와 비교하여 Spot 엔티티의 이미지를 업데이트합니다. + * 기존 이미지 URL과 새로운 이미지 URL들이 모두 다른 경우에만 업데이트합니다. + * + * @param spot DB에서 조회한 기존 Spot 엔티티 + * @param item 공통정보 조회 API로부터 받은 응답 + */ + private void updateSpotImage(Spot spot, SpotApiResponseDto.Item item) { + String oldOriginalUrl = spot.getSpotImage() != null ? spot.getSpotImage().getOriginalUrl() : null; + + String newOriginalImage = item.getSpotOriginalImage(); + String newThumbnailImage = item.getSpotThumbnailImage(); + + if (!Objects.equals(oldOriginalUrl, newOriginalImage) && !Objects.equals(oldOriginalUrl, newThumbnailImage)) { + SpotImage spotImage = createSpotImage(item); + if (spotImage != null) { + spot.setSpotImage(spotImage); + fileService.deleteFile(oldOriginalUrl); + } + } + } + + /** + * API 데이터와 비교하여 Spot을 업데이트합니다. + * 기존 DB에 있는 Spot과 비교하여 변경 사항이 있을 경우 업데이트합니다. + * + * @param items 공통 관광정보 API에서 가져온 새로운 Spot 데이터 + * @return 업데이트된 Spot List + */ + private List updateSpots(List items) { + List updatedSpots = new ArrayList<>(); + + for (SpotApiResponseDto.Item item : items) { + Optional newSpot = createSpot(item); + if (newSpot.isPresent()) { + Spot spot = spotRepository.findByExternalId(item.getSpotExternalId()); + if (spot != null) { + boolean isUpdated = spot.syncWith(newSpot.get()); + updateSpotImage(spot, item); + if (isUpdated) { + updatedSpots.add(spot); + } + } + } + } + + return updatedSpots; + } + + /** + * showflag가 0인 Spot의 External Id 목록을 받아 DB와 S3 버킷에서 삭제합니다. + * + * @param toDelete 삭제할 Spot의 External Id Set + * @return 삭제된 Spot의 개수 + */ + private int deleteSpots(Set toDelete) { + int deletedSpotCount = 0; + + for (String externalId : toDelete) { + Spot spot = spotRepository.findByExternalId(externalId); + if (spot != null) { + spotRepository.delete(spot); + fileService.deleteFile(spot.getSpotImage().getImgUrl()); + deletedSpotCount++; + } + } + + return deletedSpotCount; + } + /** * API 응답값의 필드가 null이거나 빈 문자열인지 검사합니다. * null 또는 빈 문자열일 경우 객체를 생성하지 않고 넘어갑니다. @@ -292,7 +603,7 @@ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { */ private boolean isFieldValid(String value, String fieldName, String externalId) { if (value == null || value.isBlank()) { - log.warn("[즐길거리 수정] API 응답값 필드가 null 또는 빈 문자열이어서 건너뜀: externalId={}, fieldName={}", externalId, fieldName); + log.warn("API 응답값 필드가 null 또는 빈 문자열이어서 건너뜀: externalId={}, fieldName={}", externalId, fieldName); return false; } @@ -313,7 +624,7 @@ private boolean isFieldDouble(String doubleString, String fieldName, String exte Double.parseDouble(doubleString); return true; } catch (NumberFormatException e) { - log.warn("[즐길거리 수정] API 응답값 필드가 Double로 변환되지 않아 건너뜀: externalId={}, fieldName={}", externalId, fieldName); + log.warn("API 응답값 필드가 Double로 변환되지 않아 건너뜀: externalId={}, fieldName={}", externalId, fieldName); return false; } } diff --git a/src/main/java/com/server/running_handai/global/config/AppConfig.java b/src/main/java/com/server/running_handai/global/config/AppConfig.java index a710f38..aa14709 100644 --- a/src/main/java/com/server/running_handai/global/config/AppConfig.java +++ b/src/main/java/com/server/running_handai/global/config/AppConfig.java @@ -28,6 +28,8 @@ public ObjectMapper objectMapper() { ObjectMapper objectMapper = new ObjectMapper(); // OpenAI API 관련에서 발생하는 알 수 없는 필드가 있다는 예외를 무시하기 위함 objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // 빈 문자열을 null 객체로 허용해 JSON 파싱 오류 방지 + objectMapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); return objectMapper; }