From aa6f6c83fef70ed94d46fde5f326f12a402805c5 Mon Sep 17 00:00:00 2001 From: sumi Date: Tue, 9 Sep 2025 11:02:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?wip:=20=EC=9E=A5=EC=86=8C=EB=B3=84=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=20=EA=B4=80=EA=B4=91=EC=A7=80=20DB=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20(=EC=8A=A4=EC=BC=80=EC=A4=84=EB=A7=81,=20T?= =?UTF-8?q?ourAPI=20=EC=97=B0=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mey/backend/BackendApplication.java | 2 + .../domain/place/dto/DetailCommonItemDto.java | 19 ++ .../domain/place/dto/PlaceResponseDto.java | 2 +- .../domain/place/dto/RelatedPlaceItemDto.java | 9 + .../backend/domain/place/entity/Place.java | 39 +++- .../place/repository/PlaceRepository.java | 3 + .../domain/place/service/PlaceFactory.java | 71 ++++++ .../service/PlaceMultilangUpsertService.java | 61 +++++ .../service/PlaceRelationRefreshJob.java | 22 ++ .../service/PlaceRelationRefreshService.java | 92 ++++++++ .../place/service/PlaceTourApiClient.java | 221 ++++++++++++++++++ .../domain/place/service/RegionResolver.java | 53 +++++ .../backend/domain/region/entity/Region.java | 7 + .../region/repository/RegionRepository.java | 4 + .../domain/route/service/RouteService.java | 2 +- src/main/resources/application.yml | 11 + 16 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/mey/backend/domain/place/dto/DetailCommonItemDto.java create mode 100644 src/main/java/com/mey/backend/domain/place/dto/RelatedPlaceItemDto.java create mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceFactory.java create mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceMultilangUpsertService.java create mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshJob.java create mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshService.java create mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java create mode 100644 src/main/java/com/mey/backend/domain/place/service/RegionResolver.java diff --git a/src/main/java/com/mey/backend/BackendApplication.java b/src/main/java/com/mey/backend/BackendApplication.java index 52bd6b9..c4e0c92 100644 --- a/src/main/java/com/mey/backend/BackendApplication.java +++ b/src/main/java/com/mey/backend/BackendApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing +@EnableScheduling @SpringBootApplication public class BackendApplication { diff --git a/src/main/java/com/mey/backend/domain/place/dto/DetailCommonItemDto.java b/src/main/java/com/mey/backend/domain/place/dto/DetailCommonItemDto.java new file mode 100644 index 0000000..6cd48b2 --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/dto/DetailCommonItemDto.java @@ -0,0 +1,19 @@ +package com.mey.backend.domain.place.dto; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class DetailCommonItemDto { + String contentId; + String title; + String overview; + String addr; + String mapx; + String mapy; + String areaCode; + String siGunGuCode; + String image; +} + diff --git a/src/main/java/com/mey/backend/domain/place/dto/PlaceResponseDto.java b/src/main/java/com/mey/backend/domain/place/dto/PlaceResponseDto.java index 359613d..48f641d 100644 --- a/src/main/java/com/mey/backend/domain/place/dto/PlaceResponseDto.java +++ b/src/main/java/com/mey/backend/domain/place/dto/PlaceResponseDto.java @@ -44,7 +44,7 @@ public PlaceResponseDto(Place place) { this.longitude = place.getLongitude(); this.latitude = place.getLatitude(); this.imageUrl = place.getImageUrl(); - this.address = place.getAddress(); + this.address = place.getAddressKo(); this.contactInfo = place.getContactInfo(); this.websiteUrl = place.getWebsiteUrl(); this.kakaoPlaceId = place.getKakaoPlaceId(); diff --git a/src/main/java/com/mey/backend/domain/place/dto/RelatedPlaceItemDto.java b/src/main/java/com/mey/backend/domain/place/dto/RelatedPlaceItemDto.java new file mode 100644 index 0000000..725c27b --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/dto/RelatedPlaceItemDto.java @@ -0,0 +1,9 @@ +package com.mey.backend.domain.place.dto; + +import lombok.Value; + +@Value +public class RelatedPlaceItemDto { + String rlteTatsCd; + String rlteTatsNm; // 연관 관광지 이름 +} diff --git a/src/main/java/com/mey/backend/domain/place/entity/Place.java b/src/main/java/com/mey/backend/domain/place/entity/Place.java index 052f6cc..e707fa8 100644 --- a/src/main/java/com/mey/backend/domain/place/entity/Place.java +++ b/src/main/java/com/mey/backend/domain/place/entity/Place.java @@ -32,12 +32,25 @@ public class Place extends BaseTimeEntity { @Column(nullable = false) private String nameEn; + @Column(nullable = false) + private String nameJp; + + @Column(nullable = false) + private String nameCh; + @Column(nullable = false, columnDefinition = "TEXT") private String descriptionKo; @Column(nullable = false, columnDefinition = "TEXT") private String descriptionEn; + + @Column(nullable = false, columnDefinition = "TEXT") + private String descriptionJp; + + @Column(nullable = false, columnDefinition = "TEXT") + private String descriptionCh; + @Column(nullable = false) private Double longitude; @@ -48,7 +61,16 @@ public class Place extends BaseTimeEntity { private String imageUrl; @Column(nullable = false) - private String address; + private String addressKo; + + @Column(nullable = false) + private String addressEn; + + @Column(nullable = false) + private String addressJp; + + @Column(nullable = false) + private String addressCh; private String contactInfo; @@ -56,19 +78,26 @@ public class Place extends BaseTimeEntity { private String kakaoPlaceId; + @Column(unique = true) private String tourApiPlaceId; @JdbcTypeCode(SqlTypes.JSON) - @Column(nullable = false, columnDefinition = "json") + @Column(columnDefinition = "json") private Map openingHours; // 예: "monday": "09:00-18:00" @JdbcTypeCode(SqlTypes.JSON) @Column(nullable = false, columnDefinition = "json") private List themes; -// @Column(nullable = false) -// private String tags; + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "json") + private List relatedByPlaces; - @Column(nullable = false) private String costInfo; + + @Column(nullable = false) + private String areaCd; + + @Column(nullable = false) + private String siGunGuCd; } diff --git a/src/main/java/com/mey/backend/domain/place/repository/PlaceRepository.java b/src/main/java/com/mey/backend/domain/place/repository/PlaceRepository.java index 12945dd..5d6088a 100644 --- a/src/main/java/com/mey/backend/domain/place/repository/PlaceRepository.java +++ b/src/main/java/com/mey/backend/domain/place/repository/PlaceRepository.java @@ -19,5 +19,8 @@ public interface PlaceRepository extends JpaRepository { // JPQL의 MOD 함수로 홀수 placeId만 필터링 @Query("SELECT p FROM Place p WHERE MOD(p.placeId, 2) = 1") List findOddIdPlaces(Pageable pageable); + + Optional findByTourApiPlaceId(String tourApiPlaceId); + } diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceFactory.java b/src/main/java/com/mey/backend/domain/place/service/PlaceFactory.java new file mode 100644 index 0000000..09fa49b --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceFactory.java @@ -0,0 +1,71 @@ +package com.mey.backend.domain.place.service; + +import com.mey.backend.domain.place.dto.DetailCommonItemDto; +import com.mey.backend.domain.place.entity.Place; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class PlaceFactory { + + private final RegionResolver regionResolver; + + public Place createFromMultilang( + DetailCommonItemDto kor, + DetailCommonItemDto eng, + DetailCommonItemDto jpn, + DetailCommonItemDto chs) { + + String nameKo = nonBlank(kor != null ? kor.getTitle() : null, "미정"); + String nameEn = nonBlank(eng != null ? eng.getTitle() : null, nameKo); + String nameJp = nonBlank(jpn != null ? jpn.getTitle() : null, nameKo); + String nameCh = nonBlank(chs != null ? chs.getTitle() : null, nameKo); + + String descKo = nonBlank(kor != null ? kor.getOverview() : null, ""); + String descEn = nonBlank(eng != null ? eng.getOverview() : null, descKo); + String descJp = nonBlank(jpn != null ? jpn.getOverview() : null, descKo); + String descCh = nonBlank(chs != null ? chs.getOverview() : null, descKo); + + return Place.builder() + .region(regionResolver.resolve(kor)) + .nameKo(nameKo) + .nameEn(nameEn) + .nameJp(nameJp) + .nameCh(nameCh) + .descriptionKo(descKo) + .descriptionEn(descEn) + .descriptionJp(descJp) + .descriptionCh(descCh) + .longitude(parseDoubleOr(kor != null ? kor.getMapx() : null, 0)) + .latitude(parseDoubleOr(kor != null ? kor.getMapy() : null, 0)) + .imageUrl(nonBlank(kor != null ? kor.getImage() : null, "https://…/placeholder.png")) + .addressKo(nonBlank(kor != null ? kor.getAddr() : null, "")) + .addressEn(nonBlank(eng != null ? eng.getAddr() : null, "")) + .addressJp(nonBlank(jpn != null ? jpn.getAddr() : null, "")) + .addressJp(nonBlank(chs != null ? chs.getAddr() : null, "")) + .tourApiPlaceId(kor != null ? kor.getContentId() : null) + .openingHours(Map.of()) + .themes(List.of()) // 어떻게 채울지 고민해보자 + .areaCd(kor.getAreaCode()) + .siGunGuCd(kor.getSiGunGuCode()) + .relatedByPlaces(List.of()) + .costInfo("정보없음") + .build(); + } + + private String nonBlank(String v, String fallback) { + return (v == null || v.isBlank()) ? fallback : v; + } + + private double parseDoubleOr(String v, double fallback) { + try { + return v == null ? fallback : Double.parseDouble(v); + } catch (Exception e) { + return fallback; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceMultilangUpsertService.java b/src/main/java/com/mey/backend/domain/place/service/PlaceMultilangUpsertService.java new file mode 100644 index 0000000..e1cea94 --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceMultilangUpsertService.java @@ -0,0 +1,61 @@ +package com.mey.backend.domain.place.service; + +import com.mey.backend.domain.place.dto.DetailCommonItemDto; +import com.mey.backend.domain.place.entity.Place; +import com.mey.backend.domain.place.repository.PlaceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PlaceMultilangUpsertService { + + private final PlaceRepository placeRepository; + private final PlaceTourApiClient tourApiClient; // Kor/Eng/Jpn/Chs 각각 detailCommon2 호출 기능 + private final PlaceFactory placeFactory; // 다국어 DTO -> Place 엔티티 변환 + + @Transactional + public Place upsertByContentId(String contentId) { + log.info("[upsertByContentId] 요청 contentId={}", contentId); + + return placeRepository.findByTourApiPlaceId(contentId) + .map(place -> { + log.info("[upsertByContentId] DB에서 기존 Place 발견 contentId={}", contentId); + return place; + }) + .orElseGet(() -> { + log.info("[upsertByContentId] DB에 없음, 새 Place 생성 시작 contentId={}", contentId); + return createNewPlaceByContentId(contentId); + }); + } + + private Place createNewPlaceByContentId(String contentId) { + log.debug("[createNewPlaceByContentId] contentId={} - 한글 detailCommon2 호출", contentId); + DetailCommonItemDto kor = tourApiClient.fetchKorDetailCommon(contentId); + log.debug("[createNewPlaceByContentId] contentId={} - fetchKorDetailCommon 호출 완료", contentId); + if (kor == null) { + log.error("[createNewPlaceByContentId] detailCommon2(KOR) 결과 없음 contentId={}", contentId); + throw new IllegalStateException("detailCommon2(KOR) not found for contentId=" + contentId); + } + log.info("[createNewPlaceByContentId] 한글 정보 로드 완료 contentId={} name={}", contentId, kor.getTitle()); + + DetailCommonItemDto eng = tourApiClient.fetchEngDetailCommon(contentId); + DetailCommonItemDto jpn = tourApiClient.fetchJpnDetailCommon(contentId); + DetailCommonItemDto chs = tourApiClient.fetchChsDetailCommon(contentId); + log.debug("[createNewPlaceByContentId] 다국어 정보 로드 완료 contentId={} eng?={} jpn?={} chs?={}", + contentId, eng != null, jpn != null, chs != null); + + Place newPlace = placeFactory.createFromMultilang(kor, eng, jpn, chs); + log.info("[createNewPlaceByContentId] Place 엔티티 변환 완료 contentId={} nameKo={}", + contentId, newPlace.getNameKo()); + + Place saved = placeRepository.save(newPlace); + log.info("[createNewPlaceByContentId] Place 저장 완료 placeId={} contentId={}", + saved.getPlaceId(), contentId); + + return saved; + } +} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshJob.java b/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshJob.java new file mode 100644 index 0000000..00b779f --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshJob.java @@ -0,0 +1,22 @@ +package com.mey.backend.domain.place.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PlaceRelationRefreshJob { + + private final PlaceRelationRefreshService refreshService; + + @Scheduled(cron = "*/30 * * * * *") + public void runDaily() { + + log.info("PlaceRelationRefreshJob 실행 전"); + refreshService.refreshAllRelatedPlaces(); + log.info("PlaceRelationRefreshJob 실행 완료"); + } +} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshService.java b/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshService.java new file mode 100644 index 0000000..9786b63 --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshService.java @@ -0,0 +1,92 @@ +package com.mey.backend.domain.place.service; + +import com.mey.backend.domain.place.entity.Place; +import com.mey.backend.domain.place.repository.PlaceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PlaceRelationRefreshService { + + private final PlaceRepository placeRepository; + private final PlaceTourApiClient tourApiClient; + private final PlaceMultilangUpsertService upsertService; + + private static final int MAX_RELATED = 20; + + /** 전체 Place 페이징 순회 */ + public void refreshAllRelatedPlaces() { + log.info("[RelationRefresh] 시작"); + int page = 0, size = 200; + Page chunk; + do { + chunk = placeRepository.findAll(PageRequest.of(page++, size)); + log.info("[RelationRefresh] 페이지 {} size={} count={}", page, size, chunk.getNumberOfElements()); + chunk.forEach(this::refreshOnePlaceSafely); + } while (chunk.hasNext()); + log.info("[RelationRefresh] 종료"); + } + + private void refreshOnePlaceSafely(Place place) { + log.debug("[RelationRefresh] 시작 placeId={} name={}", place.getPlaceId(), place.getNameKo()); + try { + refreshOnePlace(place); + log.info("[RelationRefresh] 성공 placeId={} name={}", place.getPlaceId(), place.getNameKo()); + } catch (Exception e) { + log.warn("[RelationRefresh] placeId={} 실패: {}", place.getPlaceId(), e.getMessage(), e); + } finally { + log.debug("[RelationRefresh] 종료 placeId={} name={}", place.getPlaceId(), place.getNameKo()); + } + } + + // 1개 Place에 대해: nameKo → 연관 contentId → Place 업서트 → relatedByPlaces 교체 + @Transactional + public void refreshOnePlace(Place place) { + log.info("[RelationRefresh] placeId={} nameKo={} areaCd={} siGunGuCd={}", + place.getPlaceId(), place.getNameKo(), place.getAreaCd(), place.getSiGunGuCd()); + + List relatedIds = new ArrayList<>(); + + // API 호출 + var relatedItems = tourApiClient.fetchRelatedPlaces( + place.getNameKo(), + "202508", + place.getAreaCd(), + place.getSiGunGuCd() + ); + + if (relatedItems == null || relatedItems.isEmpty()) { + log.info("[RelationRefresh] placeId={} → 연관 결과 없음", place.getPlaceId()); + } else { + log.info("[RelationRefresh] placeId={} → {}개 연관 아이템", place.getPlaceId(), relatedItems.size()); + } + + // 결과 처리 + if (relatedItems != null) { + for (var it : relatedItems) { + if (relatedIds.size() >= MAX_RELATED) break; + String cid = it.getRlteTatsCd(); + + log.debug("[RelationRefresh] placeId={} → 업서트 cid={}", place.getPlaceId(), cid); + Place related = upsertService.upsertByContentId(cid); + + if (!Objects.equals(related.getPlaceId(), place.getPlaceId())) { + relatedIds.add(related.getPlaceId()); + } + } + } + + // 중복 제거 후 업데이트 + List distinct = relatedIds.stream().distinct().toList(); + place.setRelatedByPlaces(distinct); + log.info("[RelationRefresh] placeId={} → relatedByPlaces {}개 저장", place.getPlaceId(), distinct.size()); + } +} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java b/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java new file mode 100644 index 0000000..48c79f0 --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java @@ -0,0 +1,221 @@ +package com.mey.backend.domain.place.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mey.backend.domain.place.dto.DetailCommonItemDto; +import com.mey.backend.domain.place.dto.RelatedPlaceItemDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PlaceTourApiClient { + + private final ObjectMapper om = new ObjectMapper(); + + @Value("${tourapi.service-key}") private String serviceKey; + @Value("${tourapi.mobile-os}") private String mobileOs; + @Value("${tourapi.mobile-app}") private String mobileApp; + @Value("${tourapi.base.related}") private String relatedBase; + @Value("${tourapi.base.kor}") private String korBase; + @Value("${tourapi.base.eng}") private String engBase; + @Value("${tourapi.base.jpn}") private String jpnBase; + @Value("${tourapi.base.chs}") private String chsBase; + + private RestClient client(String baseUrl) { + return RestClient.builder().baseUrl(baseUrl).build(); + } + + + private String safeHead(String body, int max) { + if (body == null) return "null"; + String t = body.trim(); + return t.substring(0, Math.min(max, t.length())); + } + + // 1) 연관관광지: TarRlteTarService1/searchKeyword1 + // 필수: baseYm(YYYYMM), areaCd, signguCd, keyword + public List fetchRelatedPlaces(String keyword, String baseYm, String areaCd, String signguCd) { + + String encodedKey = URLEncoder.encode(serviceKey, StandardCharsets.UTF_8); + String encodedKeword = URLEncoder.encode(keyword, StandardCharsets.UTF_8); + URI uri = UriComponentsBuilder + .fromUriString(relatedBase) + .path("/searchKeyword1") + .queryParam("serviceKey", encodedKey) + .queryParam("MobileOS", mobileOs) + .queryParam("MobileApp", mobileApp) + .queryParam("_type", "json") + .queryParam("baseYm", baseYm) + .queryParam("areaCd", areaCd) + .queryParam("signguCd", signguCd) + .queryParam("keyword", encodedKeword) + .build(true).toUri(); + + log.info("▶ TourAPI(Related) 호출: {}", uri); + + String body = null; + try { + body = RestClient.builder().baseUrl("") + .build() + .get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String.class); + log.info("◀ TourAPI 응답(앞 300자): {}", safeHead(body, 300)); + } catch (Exception e) { + log.error("❌ TourAPI 호출 실패 keyword={}, uri={}", keyword, uri, e); + return List.of(); + } + + if (body == null || body.isBlank()) { + log.error("❌ TourAPI 빈 응답 keyword={}", keyword); + return List.of(); + } + + String trimmed = body.trim(); + if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { + // XML 에러 응답 처리 + String resultMsg = null, authMsg = null; + try { + Matcher m1 = Pattern.compile("(.*?)").matcher(trimmed); + if (m1.find()) resultMsg = m1.group(1); + Matcher m2 = Pattern.compile("(.*?)").matcher(trimmed); + if (m2.find()) authMsg = m2.group(1); + } catch (Exception ignore) {} + log.error("❌ TourAPI XML 에러 keyword={}, resultMsg={}, returnAuthMsg={}, head={}", + keyword, resultMsg, authMsg, safeHead(trimmed, 200)); + return List.of(); + } + + try { + List out = new ArrayList<>(); + JsonNode items = om.readTree(trimmed).at("/response/body/items/item"); + if (items.isArray()) { + for (JsonNode it : items) { + out.add(new RelatedPlaceItemDto( + it.path("rlteTatsCd").asText(null), + it.path("rlteTatsNm").asText(null) + )); + } + } else if (!items.isMissingNode()) { + out.add(new RelatedPlaceItemDto( + items.path("rlteTatsCd").asText(null), + items.path("rlteTatsNm").asText(null) + )); + } + log.info("✔ TourAPI 파싱 성공 keyword={}, 결과 {}건", keyword, out.size()); + return out; + } catch (Exception e) { + log.error("❌ TourAPI JSON 파싱 실패 keyword={}, bodyHead={}", keyword, safeHead(trimmed, 200), e); + return List.of(); + } + } + + // 2) 다국어 detailCommon2 (Kor/Eng/Jpn/ChsService2 하위) + private DetailCommonItemDto fetchDetailCommon(String baseUrl, String contentId) { + + String encodedKey = URLEncoder.encode(serviceKey, StandardCharsets.UTF_8); + + URI uri = UriComponentsBuilder + .fromUriString(baseUrl) + .path("/detailCommon2") + .queryParam("serviceKey", encodedKey) + .queryParam("MobileOS", mobileOs) + .queryParam("MobileApp", mobileApp) + .queryParam("_type", "json") + .queryParam("contentId", contentId) + .queryParam("defaultYN", "Y") + .queryParam("firstImageYN", "Y") + .queryParam("areacodeYN", "Y") + .queryParam("addrinfoYN", "Y") + .queryParam("mapinfoYN", "Y") + .queryParam("overviewYN", "Y") + .build(true) + .toUri(); + + log.info("▶ TourAPI(detailCommon2) 호출: {}", uri); + + String body = null; + try { + body = RestClient.builder().baseUrl("") + .build() + .get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String.class); + log.info("◀ TourAPI 응답(앞 300자): {}", safeHead(body, 300)); + } catch (Exception e) { + log.error("❌ TourAPI 호출 실패 detailCommon2 contentId={}, uri={}", contentId, uri, e); + return null; + } + + if (body == null || body.isBlank()) { + log.error("❌ TourAPI 빈 응답 detailCommon2 contentId={}", contentId); + return null; + } + + String trimmed = body.trim(); + if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { + // XML 에러 응답 처리 + String resultMsg = null, authMsg = null; + try { + Matcher m1 = Pattern.compile("(.*?)").matcher(trimmed); + if (m1.find()) resultMsg = m1.group(1); + Matcher m2 = Pattern.compile("(.*?)").matcher(trimmed); + if (m2.find()) authMsg = m2.group(1); + } catch (Exception ignore) {} + log.error("❌ TourAPI XML 에러 detailCommon2 contentId={}, resultMsg={}, returnAuthMsg={}, head={}", + contentId, resultMsg, authMsg, safeHead(trimmed, 200)); + return null; + } + + try { + JsonNode item = om.readTree(trimmed).at("/response/body/items/item"); + if (item == null || item.isMissingNode() || item.isNull()) { + log.warn("⚠ TourAPI detailCommon2 contentId={} 결과 없음", contentId); + return null; + } + + DetailCommonItemDto dto = DetailCommonItemDto.builder() + .contentId(item.path("contentid").asText(null)) + .title(item.path("title").asText(null)) + .overview(item.path("overview").asText(null)) + .addr(item.path("addr1").asText(null)) + .mapx(item.path("mapx").asText(null)) + .mapy(item.path("mapy").asText(null)) + .areaCode(item.path("areacode").asText(null)) + .siGunGuCode(item.path("siGungu").asText(null)) + .image(item.path("firstimage").asText(null)) + .build(); + + log.info("✔ TourAPI 파싱 성공 detailCommon2 contentId={}", contentId); + return dto; + } catch (Exception e) { + log.error("❌ TourAPI JSON 파싱 실패 detailCommon2 contentId={}, bodyHead={}", + contentId, safeHead(trimmed, 200), e); + return null; + } + } + + public DetailCommonItemDto fetchKorDetailCommon(String contentId) { return fetchDetailCommon(korBase, contentId); } + public DetailCommonItemDto fetchEngDetailCommon(String contentId) { return fetchDetailCommon(engBase, contentId); } + public DetailCommonItemDto fetchJpnDetailCommon(String contentId) { return fetchDetailCommon(jpnBase, contentId); } + public DetailCommonItemDto fetchChsDetailCommon(String contentId) { return fetchDetailCommon(chsBase, contentId); } +} diff --git a/src/main/java/com/mey/backend/domain/place/service/RegionResolver.java b/src/main/java/com/mey/backend/domain/place/service/RegionResolver.java new file mode 100644 index 0000000..49447b9 --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/service/RegionResolver.java @@ -0,0 +1,53 @@ +package com.mey.backend.domain.place.service; + +import com.mey.backend.domain.place.dto.DetailCommonItemDto; +import com.mey.backend.domain.region.entity.Region; +import com.mey.backend.domain.region.repository.RegionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class RegionResolver { + + private final RegionRepository regionRepository; + + // areaCode -> Region.nameKo 매핑 테이블 + private static final Map AREA_CODE_TO_REGION = Map.ofEntries( + Map.entry(11, "서울"), + Map.entry(26, "부산"), + Map.entry(27, "대구"), + Map.entry(28, "인천"), + Map.entry(29, "광주"), + Map.entry(30, "대전"), + Map.entry(31, "울산"), + Map.entry(36, "세종"), + Map.entry(41, "경기"), + Map.entry(43, "충북"), + Map.entry(44, "충남"), + Map.entry(46, "전남"), + Map.entry(47, "경북"), + Map.entry(48, "경남"), + Map.entry(50, "제주"), + Map.entry(51, "강원"), + Map.entry(52, "전북") + ); + + public Region resolve(DetailCommonItemDto kor) { + try { + int areaCode = Integer.parseInt(kor.getAreaCode()); + String regionName = AREA_CODE_TO_REGION.get(areaCode); + + if (regionName == null) { + throw new IllegalStateException("Unknown areaCode=" + areaCode); + } + + return regionRepository.findByNameKo(regionName) + .orElseThrow(() -> new IllegalStateException("Region not found in DB: " + regionName)); + } catch (Exception e) { + throw new IllegalStateException("RegionResolver failed for contentId=" + kor.getContentId(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/region/entity/Region.java b/src/main/java/com/mey/backend/domain/region/entity/Region.java index ef34e62..134f3ea 100644 --- a/src/main/java/com/mey/backend/domain/region/entity/Region.java +++ b/src/main/java/com/mey/backend/domain/region/entity/Region.java @@ -21,4 +21,11 @@ public class Region { @Column(nullable = false) private String nameEn; + + @Column(nullable = false) + private String nameJp; + + @Column(nullable = false) + private String nameCh; + } \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/region/repository/RegionRepository.java b/src/main/java/com/mey/backend/domain/region/repository/RegionRepository.java index 378f9a3..659c910 100644 --- a/src/main/java/com/mey/backend/domain/region/repository/RegionRepository.java +++ b/src/main/java/com/mey/backend/domain/region/repository/RegionRepository.java @@ -3,5 +3,9 @@ import com.mey.backend.domain.region.entity.Region; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface RegionRepository extends JpaRepository { + + Optional findByNameKo(String nameKo); } \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/route/service/RouteService.java b/src/main/java/com/mey/backend/domain/route/service/RouteService.java index c3134d2..76fdcbf 100644 --- a/src/main/java/com/mey/backend/domain/route/service/RouteService.java +++ b/src/main/java/com/mey/backend/domain/route/service/RouteService.java @@ -339,7 +339,7 @@ private RouteDetailResponseDto.PlaceDto buildPlaceDto(RoutePlace routePlace) { .description(routePlace.getPlace().getDescriptionKo()) .latitude(BigDecimal.valueOf(routePlace.getPlace().getLatitude())) .longitude(BigDecimal.valueOf(routePlace.getPlace().getLongitude())) - .address(routePlace.getPlace().getAddress()) + .address(routePlace.getPlace().getAddressKo()) .imageUrls(Arrays.asList(routePlace.getPlace().getImageUrl())) .openingHours(routePlace.getPlace().getOpeningHours()) .build(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5ca6317..2204b2e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,6 +29,17 @@ tmap: lang: 0 # 0=Korean count: 1 +tourapi: + service-key: ${TOURAPI_SERVICE_KEY} + mobile-os: "ETC" + mobile-app: "KRoute" + base: + related: http://apis.data.go.kr/B551011/TarRlteTarService1 + kor: http://apis.data.go.kr/B551011/KorService2 + eng: http://apis.data.go.kr/B551011/EngService2 + jpn: http://apis.data.go.kr/B551011/JpnService2 + chs: http://apis.data.go.kr/B551011/ChsService2 + cloud: aws: s3: From e2d94d491dc76d54803b06ae995b9d7139b9ff70 Mon Sep 17 00:00:00 2001 From: sumi Date: Thu, 11 Sep 2025 13:40:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=9E=A5=EC=86=8C=20ID=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B4=91=EC=A7=80=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KorService2/locationBasedList2로 위도·경도 기반 지역/시군구 코드 조회 - TarRlteTarService1/searchKeyword1 호출로 연관관광지 목록 실시간 조회 - KorService2/searchKeyword2 호출로 각 연관관광지 좌표 조회 후 Haversine 거리 계산 - 기존 배치 스케줄링 기반 DB 업데이트 로직 제거 → 실시간 API 응답 방식으로 전환 --- .../com/mey/backend/BackendApplication.java | 1 - .../place/controller/PlaceController.java | 12 + .../domain/place/dto/DetailCommonItemDto.java | 19 -- .../domain/place/dto/RelatedPlaceItemDto.java | 9 - .../domain/place/dto/RelatedResponseDto.java | 18 + .../backend/domain/place/entity/Place.java | 6 - .../place/repository/PlaceRepository.java | 2 +- .../domain/place/service/PlaceFactory.java | 71 ---- .../service/PlaceMultilangUpsertService.java | 61 ---- .../service/PlaceRelationRefreshJob.java | 22 -- .../service/PlaceRelationRefreshService.java | 92 ----- .../domain/place/service/PlaceService.java | 18 +- .../place/service/PlaceTourApiClient.java | 320 ++++++++++-------- .../domain/place/service/RegionResolver.java | 53 --- .../region/repository/RegionRepository.java | 1 - 15 files changed, 232 insertions(+), 473 deletions(-) delete mode 100644 src/main/java/com/mey/backend/domain/place/dto/DetailCommonItemDto.java delete mode 100644 src/main/java/com/mey/backend/domain/place/dto/RelatedPlaceItemDto.java create mode 100644 src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java delete mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceFactory.java delete mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceMultilangUpsertService.java delete mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshJob.java delete mode 100644 src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshService.java delete mode 100644 src/main/java/com/mey/backend/domain/place/service/RegionResolver.java diff --git a/src/main/java/com/mey/backend/BackendApplication.java b/src/main/java/com/mey/backend/BackendApplication.java index c4e0c92..fd43f19 100644 --- a/src/main/java/com/mey/backend/BackendApplication.java +++ b/src/main/java/com/mey/backend/BackendApplication.java @@ -6,7 +6,6 @@ import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing -@EnableScheduling @SpringBootApplication public class BackendApplication { diff --git a/src/main/java/com/mey/backend/domain/place/controller/PlaceController.java b/src/main/java/com/mey/backend/domain/place/controller/PlaceController.java index 963c494..7a219bf 100644 --- a/src/main/java/com/mey/backend/domain/place/controller/PlaceController.java +++ b/src/main/java/com/mey/backend/domain/place/controller/PlaceController.java @@ -3,6 +3,7 @@ import com.mey.backend.domain.place.dto.PlaceResponseDto; import com.mey.backend.domain.place.dto.PlaceSimpleResponseDto; import com.mey.backend.domain.place.dto.PlaceThemeResponseDto; +import com.mey.backend.domain.place.dto.RelatedResponseDto; import com.mey.backend.domain.place.service.PlaceService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,9 +35,20 @@ public List searchPlaces(@RequestParam String keyword) { ) @GetMapping("/{placeId}") public PlaceResponseDto getPlaceDetail(@PathVariable Long placeId) { + return placeService.getPlaceDetail(placeId); } + @Operation( + summary = "장소 ID로 연계 관광지 정보 조회", + description = "장소 ID로 연계 관광지 정보를 조회한 결과를 반환합니다." + ) + @GetMapping("/{placeId}/related") + public List getRelatedPlaces(@PathVariable Long placeId) { + + return placeService.getRelatedPlaces(placeId); + } + @Operation( summary = "인기 장소 조회", description = "인기 장소 리스트를 조회한 결과를 반환합니다." diff --git a/src/main/java/com/mey/backend/domain/place/dto/DetailCommonItemDto.java b/src/main/java/com/mey/backend/domain/place/dto/DetailCommonItemDto.java deleted file mode 100644 index 6cd48b2..0000000 --- a/src/main/java/com/mey/backend/domain/place/dto/DetailCommonItemDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.mey.backend.domain.place.dto; - -import lombok.Builder; -import lombok.Value; - -@Value -@Builder -public class DetailCommonItemDto { - String contentId; - String title; - String overview; - String addr; - String mapx; - String mapy; - String areaCode; - String siGunGuCode; - String image; -} - diff --git a/src/main/java/com/mey/backend/domain/place/dto/RelatedPlaceItemDto.java b/src/main/java/com/mey/backend/domain/place/dto/RelatedPlaceItemDto.java deleted file mode 100644 index 725c27b..0000000 --- a/src/main/java/com/mey/backend/domain/place/dto/RelatedPlaceItemDto.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.mey.backend.domain.place.dto; - -import lombok.Value; - -@Value -public class RelatedPlaceItemDto { - String rlteTatsCd; - String rlteTatsNm; // 연관 관광지 이름 -} diff --git a/src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java b/src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java new file mode 100644 index 0000000..8dcceb0 --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java @@ -0,0 +1,18 @@ +package com.mey.backend.domain.place.dto; + +import lombok.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class RelatedResponseDto { + + private String rlteTatsNm; // 연관관광지명 + private String rlteRegnNm; // 연관관광지 시도명 + private String rlteSignguNm; // 연관관광지 시군구명 + private String rlteCtgryLclsNm; // 대분류 + private String rlteCtgryMclsNm; // 중분류 + private String rlteCtgrySclsNm; // 소분류 + private Double distanceMeter; // 출발지→연관관광지 직선거리(m) +} diff --git a/src/main/java/com/mey/backend/domain/place/entity/Place.java b/src/main/java/com/mey/backend/domain/place/entity/Place.java index e707fa8..da1be53 100644 --- a/src/main/java/com/mey/backend/domain/place/entity/Place.java +++ b/src/main/java/com/mey/backend/domain/place/entity/Place.java @@ -94,10 +94,4 @@ public class Place extends BaseTimeEntity { private List relatedByPlaces; private String costInfo; - - @Column(nullable = false) - private String areaCd; - - @Column(nullable = false) - private String siGunGuCd; } diff --git a/src/main/java/com/mey/backend/domain/place/repository/PlaceRepository.java b/src/main/java/com/mey/backend/domain/place/repository/PlaceRepository.java index 5d6088a..0599b15 100644 --- a/src/main/java/com/mey/backend/domain/place/repository/PlaceRepository.java +++ b/src/main/java/com/mey/backend/domain/place/repository/PlaceRepository.java @@ -20,7 +20,7 @@ public interface PlaceRepository extends JpaRepository { @Query("SELECT p FROM Place p WHERE MOD(p.placeId, 2) = 1") List findOddIdPlaces(Pageable pageable); - Optional findByTourApiPlaceId(String tourApiPlaceId); + Place findPlaceByPlaceId(Long placeId); } diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceFactory.java b/src/main/java/com/mey/backend/domain/place/service/PlaceFactory.java deleted file mode 100644 index 09fa49b..0000000 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceFactory.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.mey.backend.domain.place.service; - -import com.mey.backend.domain.place.dto.DetailCommonItemDto; -import com.mey.backend.domain.place.entity.Place; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class PlaceFactory { - - private final RegionResolver regionResolver; - - public Place createFromMultilang( - DetailCommonItemDto kor, - DetailCommonItemDto eng, - DetailCommonItemDto jpn, - DetailCommonItemDto chs) { - - String nameKo = nonBlank(kor != null ? kor.getTitle() : null, "미정"); - String nameEn = nonBlank(eng != null ? eng.getTitle() : null, nameKo); - String nameJp = nonBlank(jpn != null ? jpn.getTitle() : null, nameKo); - String nameCh = nonBlank(chs != null ? chs.getTitle() : null, nameKo); - - String descKo = nonBlank(kor != null ? kor.getOverview() : null, ""); - String descEn = nonBlank(eng != null ? eng.getOverview() : null, descKo); - String descJp = nonBlank(jpn != null ? jpn.getOverview() : null, descKo); - String descCh = nonBlank(chs != null ? chs.getOverview() : null, descKo); - - return Place.builder() - .region(regionResolver.resolve(kor)) - .nameKo(nameKo) - .nameEn(nameEn) - .nameJp(nameJp) - .nameCh(nameCh) - .descriptionKo(descKo) - .descriptionEn(descEn) - .descriptionJp(descJp) - .descriptionCh(descCh) - .longitude(parseDoubleOr(kor != null ? kor.getMapx() : null, 0)) - .latitude(parseDoubleOr(kor != null ? kor.getMapy() : null, 0)) - .imageUrl(nonBlank(kor != null ? kor.getImage() : null, "https://…/placeholder.png")) - .addressKo(nonBlank(kor != null ? kor.getAddr() : null, "")) - .addressEn(nonBlank(eng != null ? eng.getAddr() : null, "")) - .addressJp(nonBlank(jpn != null ? jpn.getAddr() : null, "")) - .addressJp(nonBlank(chs != null ? chs.getAddr() : null, "")) - .tourApiPlaceId(kor != null ? kor.getContentId() : null) - .openingHours(Map.of()) - .themes(List.of()) // 어떻게 채울지 고민해보자 - .areaCd(kor.getAreaCode()) - .siGunGuCd(kor.getSiGunGuCode()) - .relatedByPlaces(List.of()) - .costInfo("정보없음") - .build(); - } - - private String nonBlank(String v, String fallback) { - return (v == null || v.isBlank()) ? fallback : v; - } - - private double parseDoubleOr(String v, double fallback) { - try { - return v == null ? fallback : Double.parseDouble(v); - } catch (Exception e) { - return fallback; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceMultilangUpsertService.java b/src/main/java/com/mey/backend/domain/place/service/PlaceMultilangUpsertService.java deleted file mode 100644 index e1cea94..0000000 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceMultilangUpsertService.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.mey.backend.domain.place.service; - -import com.mey.backend.domain.place.dto.DetailCommonItemDto; -import com.mey.backend.domain.place.entity.Place; -import com.mey.backend.domain.place.repository.PlaceRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Slf4j -public class PlaceMultilangUpsertService { - - private final PlaceRepository placeRepository; - private final PlaceTourApiClient tourApiClient; // Kor/Eng/Jpn/Chs 각각 detailCommon2 호출 기능 - private final PlaceFactory placeFactory; // 다국어 DTO -> Place 엔티티 변환 - - @Transactional - public Place upsertByContentId(String contentId) { - log.info("[upsertByContentId] 요청 contentId={}", contentId); - - return placeRepository.findByTourApiPlaceId(contentId) - .map(place -> { - log.info("[upsertByContentId] DB에서 기존 Place 발견 contentId={}", contentId); - return place; - }) - .orElseGet(() -> { - log.info("[upsertByContentId] DB에 없음, 새 Place 생성 시작 contentId={}", contentId); - return createNewPlaceByContentId(contentId); - }); - } - - private Place createNewPlaceByContentId(String contentId) { - log.debug("[createNewPlaceByContentId] contentId={} - 한글 detailCommon2 호출", contentId); - DetailCommonItemDto kor = tourApiClient.fetchKorDetailCommon(contentId); - log.debug("[createNewPlaceByContentId] contentId={} - fetchKorDetailCommon 호출 완료", contentId); - if (kor == null) { - log.error("[createNewPlaceByContentId] detailCommon2(KOR) 결과 없음 contentId={}", contentId); - throw new IllegalStateException("detailCommon2(KOR) not found for contentId=" + contentId); - } - log.info("[createNewPlaceByContentId] 한글 정보 로드 완료 contentId={} name={}", contentId, kor.getTitle()); - - DetailCommonItemDto eng = tourApiClient.fetchEngDetailCommon(contentId); - DetailCommonItemDto jpn = tourApiClient.fetchJpnDetailCommon(contentId); - DetailCommonItemDto chs = tourApiClient.fetchChsDetailCommon(contentId); - log.debug("[createNewPlaceByContentId] 다국어 정보 로드 완료 contentId={} eng?={} jpn?={} chs?={}", - contentId, eng != null, jpn != null, chs != null); - - Place newPlace = placeFactory.createFromMultilang(kor, eng, jpn, chs); - log.info("[createNewPlaceByContentId] Place 엔티티 변환 완료 contentId={} nameKo={}", - contentId, newPlace.getNameKo()); - - Place saved = placeRepository.save(newPlace); - log.info("[createNewPlaceByContentId] Place 저장 완료 placeId={} contentId={}", - saved.getPlaceId(), contentId); - - return saved; - } -} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshJob.java b/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshJob.java deleted file mode 100644 index 00b779f..0000000 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshJob.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.mey.backend.domain.place.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class PlaceRelationRefreshJob { - - private final PlaceRelationRefreshService refreshService; - - @Scheduled(cron = "*/30 * * * * *") - public void runDaily() { - - log.info("PlaceRelationRefreshJob 실행 전"); - refreshService.refreshAllRelatedPlaces(); - log.info("PlaceRelationRefreshJob 실행 완료"); - } -} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshService.java b/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshService.java deleted file mode 100644 index 9786b63..0000000 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceRelationRefreshService.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.mey.backend.domain.place.service; - -import com.mey.backend.domain.place.entity.Place; -import com.mey.backend.domain.place.repository.PlaceRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.*; - -@Slf4j -@Service -@RequiredArgsConstructor -public class PlaceRelationRefreshService { - - private final PlaceRepository placeRepository; - private final PlaceTourApiClient tourApiClient; - private final PlaceMultilangUpsertService upsertService; - - private static final int MAX_RELATED = 20; - - /** 전체 Place 페이징 순회 */ - public void refreshAllRelatedPlaces() { - log.info("[RelationRefresh] 시작"); - int page = 0, size = 200; - Page chunk; - do { - chunk = placeRepository.findAll(PageRequest.of(page++, size)); - log.info("[RelationRefresh] 페이지 {} size={} count={}", page, size, chunk.getNumberOfElements()); - chunk.forEach(this::refreshOnePlaceSafely); - } while (chunk.hasNext()); - log.info("[RelationRefresh] 종료"); - } - - private void refreshOnePlaceSafely(Place place) { - log.debug("[RelationRefresh] 시작 placeId={} name={}", place.getPlaceId(), place.getNameKo()); - try { - refreshOnePlace(place); - log.info("[RelationRefresh] 성공 placeId={} name={}", place.getPlaceId(), place.getNameKo()); - } catch (Exception e) { - log.warn("[RelationRefresh] placeId={} 실패: {}", place.getPlaceId(), e.getMessage(), e); - } finally { - log.debug("[RelationRefresh] 종료 placeId={} name={}", place.getPlaceId(), place.getNameKo()); - } - } - - // 1개 Place에 대해: nameKo → 연관 contentId → Place 업서트 → relatedByPlaces 교체 - @Transactional - public void refreshOnePlace(Place place) { - log.info("[RelationRefresh] placeId={} nameKo={} areaCd={} siGunGuCd={}", - place.getPlaceId(), place.getNameKo(), place.getAreaCd(), place.getSiGunGuCd()); - - List relatedIds = new ArrayList<>(); - - // API 호출 - var relatedItems = tourApiClient.fetchRelatedPlaces( - place.getNameKo(), - "202508", - place.getAreaCd(), - place.getSiGunGuCd() - ); - - if (relatedItems == null || relatedItems.isEmpty()) { - log.info("[RelationRefresh] placeId={} → 연관 결과 없음", place.getPlaceId()); - } else { - log.info("[RelationRefresh] placeId={} → {}개 연관 아이템", place.getPlaceId(), relatedItems.size()); - } - - // 결과 처리 - if (relatedItems != null) { - for (var it : relatedItems) { - if (relatedIds.size() >= MAX_RELATED) break; - String cid = it.getRlteTatsCd(); - - log.debug("[RelationRefresh] placeId={} → 업서트 cid={}", place.getPlaceId(), cid); - Place related = upsertService.upsertByContentId(cid); - - if (!Objects.equals(related.getPlaceId(), place.getPlaceId())) { - relatedIds.add(related.getPlaceId()); - } - } - } - - // 중복 제거 후 업데이트 - List distinct = relatedIds.stream().distinct().toList(); - place.setRelatedByPlaces(distinct); - log.info("[RelationRefresh] placeId={} → relatedByPlaces {}개 저장", place.getPlaceId(), distinct.size()); - } -} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceService.java b/src/main/java/com/mey/backend/domain/place/service/PlaceService.java index c83f3c6..9a10e80 100644 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceService.java +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceService.java @@ -3,9 +3,9 @@ import com.mey.backend.domain.place.dto.PlaceResponseDto; import com.mey.backend.domain.place.dto.PlaceSimpleResponseDto; import com.mey.backend.domain.place.dto.PlaceThemeResponseDto; +import com.mey.backend.domain.place.dto.RelatedResponseDto; import com.mey.backend.domain.place.entity.Place; import com.mey.backend.domain.place.repository.PlaceRepository; -import com.mey.backend.domain.place.repository.UserLikePlaceRepository; import com.mey.backend.global.exception.PlaceException; import com.mey.backend.global.payload.status.ErrorStatus; import lombok.RequiredArgsConstructor; @@ -23,7 +23,7 @@ public class PlaceService { private final PlaceRepository placeRepository; - private final UserLikePlaceRepository userLikePlaceRepository; + private final PlaceTourApiClient tourApiClient; public List searchPlaces(String keyword) { @@ -40,6 +40,20 @@ public PlaceResponseDto getPlaceDetail(Long placeId) { return new PlaceResponseDto(place); } + public List getRelatedPlaces(Long placeId) { + + Place place = placeRepository.findPlaceByPlaceId(placeId); + + String[] getAreaCdSiGunGuCd = tourApiClient.fetchRegionCodesByLocation(place.getLatitude(), place.getLongitude()); + + return tourApiClient.fetchRelatedPlacesInfo( + place, + "202508", + getAreaCdSiGunGuCd[0], + getAreaCdSiGunGuCd[1] + ); + } + @Transactional(readOnly = true) public List getPopularPlaces(Integer limit) { int n = (limit == null || limit <= 0) ? 10 : limit; diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java b/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java index 48c79f0..cd2e51c 100644 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.mey.backend.domain.place.dto.DetailCommonItemDto; -import com.mey.backend.domain.place.dto.RelatedPlaceItemDto; +import com.mey.backend.domain.place.dto.RelatedResponseDto; +import com.mey.backend.domain.place.entity.Place; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -17,8 +17,6 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @Slf4j @Component @@ -27,32 +25,81 @@ public class PlaceTourApiClient { private final ObjectMapper om = new ObjectMapper(); - @Value("${tourapi.service-key}") private String serviceKey; - @Value("${tourapi.mobile-os}") private String mobileOs; - @Value("${tourapi.mobile-app}") private String mobileApp; - @Value("${tourapi.base.related}") private String relatedBase; - @Value("${tourapi.base.kor}") private String korBase; - @Value("${tourapi.base.eng}") private String engBase; - @Value("${tourapi.base.jpn}") private String jpnBase; - @Value("${tourapi.base.chs}") private String chsBase; - - private RestClient client(String baseUrl) { - return RestClient.builder().baseUrl(baseUrl).build(); - } + @Value("${tourapi.service-key}") + private String serviceKey; + @Value("${tourapi.mobile-os}") + private String mobileOs; + @Value("${tourapi.mobile-app}") + private String mobileApp; + @Value("${tourapi.base.related}") + private String relatedBase; + @Value("${tourapi.base.kor}") + private String korBase; + @Value("${tourapi.base.eng}") + private String engBase; + @Value("${tourapi.base.jpn}") + private String jpnBase; + @Value("${tourapi.base.chs}") + private String chsBase; - private String safeHead(String body, int max) { - if (body == null) return "null"; - String t = body.trim(); - return t.substring(0, Math.min(max, t.length())); + // TourAPI locationBasedList2 호출해서 areaCode, sigunguCode 반환 + public String[] fetchRegionCodesByLocation(double latitude, double longitude) { + String encodedKey = URLEncoder.encode(serviceKey, StandardCharsets.UTF_8); + + URI uri = UriComponentsBuilder + .fromUriString(korBase) + .path("/locationBasedList2") + .queryParam("serviceKey", encodedKey) + .queryParam("MobileOS", mobileOs) + .queryParam("MobileApp", mobileApp) + .queryParam("_type", "json") + .queryParam("mapX", longitude) // 경도 + .queryParam("mapY", latitude) // 위도 + .queryParam("radius", 700) // 1000m 반경 + .queryParam("numOfRows", 1) // 한 건만 조회 + .queryParam("pageNo", 1) + .build(true).toUri(); + + try { + String body = RestClient.builder().baseUrl("") + .build() + .get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String.class); + + JsonNode items = om.readTree(body).at("/response/body/items/item"); + + JsonNode target = items.isArray() && items.size() > 0 ? items.get(0) : items; + + if (target != null && !target.isMissingNode()) { + String areaCode = target.path("lDongRegnCd").asText(null); + String sigunguCode = target.path("lDongSignguCd").asText(null); + + if (Integer.parseInt(areaCode) > 0) { + log.info("✅ 좌표→행정코드 변환 성공 lat={}, lon={}, area={}, sigungu={}", + latitude, longitude, areaCode, sigunguCode); + return new String[]{areaCode, sigunguCode}; + } else { + log.warn("⚠️ 지역코드 없음 lat={}, lon={}, raw={}", latitude, longitude, target.toPrettyString()); + } + } else { + log.warn("⚠️ TourAPI locationBasedList2 결과 없음 lat={}, lon={}", latitude, longitude); + } + } catch (Exception e) { + log.error("❌ TourAPI locationBasedList2 호출 실패 lat={}, lon={}", latitude, longitude, e); + } + return null; } - // 1) 연관관광지: TarRlteTarService1/searchKeyword1 - // 필수: baseYm(YYYYMM), areaCd, signguCd, keyword - public List fetchRelatedPlaces(String keyword, String baseYm, String areaCd, String signguCd) { + public List fetchRelatedPlacesInfo( + Place place, String baseYm, String areaCd, String sigunguCd) { String encodedKey = URLEncoder.encode(serviceKey, StandardCharsets.UTF_8); - String encodedKeword = URLEncoder.encode(keyword, StandardCharsets.UTF_8); + String encodedKeyword = URLEncoder.encode(place.getNameKo(), StandardCharsets.UTF_8); + String fullSigunguCd = areaCd+ sigunguCd; URI uri = UriComponentsBuilder .fromUriString(relatedBase) .path("/searchKeyword1") @@ -62,160 +109,163 @@ public List fetchRelatedPlaces(String keyword, String baseY .queryParam("_type", "json") .queryParam("baseYm", baseYm) .queryParam("areaCd", areaCd) - .queryParam("signguCd", signguCd) - .queryParam("keyword", encodedKeword) + .queryParam("signguCd", fullSigunguCd) + .queryParam("keyword", encodedKeyword) .build(true).toUri(); - log.info("▶ TourAPI(Related) 호출: {}", uri); - - String body = null; try { - body = RestClient.builder().baseUrl("") + String body = RestClient.builder().baseUrl("") .build() .get() .uri(uri) .accept(MediaType.APPLICATION_JSON) .retrieve() .body(String.class); - log.info("◀ TourAPI 응답(앞 300자): {}", safeHead(body, 300)); - } catch (Exception e) { - log.error("❌ TourAPI 호출 실패 keyword={}, uri={}", keyword, uri, e); - return List.of(); - } - if (body == null || body.isBlank()) { - log.error("❌ TourAPI 빈 응답 keyword={}", keyword); - return List.of(); - } + log.info("📡 TourAPI searchKeyword1 호출 placeId={}, uri={}", place.getNameKo(), uri); + log.debug("📡 searchKeyword1 응답 bodyHead={}", safeHead(body, 200)); - String trimmed = body.trim(); - if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { - // XML 에러 응답 처리 - String resultMsg = null, authMsg = null; - try { - Matcher m1 = Pattern.compile("(.*?)").matcher(trimmed); - if (m1.find()) resultMsg = m1.group(1); - Matcher m2 = Pattern.compile("(.*?)").matcher(trimmed); - if (m2.find()) authMsg = m2.group(1); - } catch (Exception ignore) {} - log.error("❌ TourAPI XML 에러 keyword={}, resultMsg={}, returnAuthMsg={}, head={}", - keyword, resultMsg, authMsg, safeHead(trimmed, 200)); - return List.of(); - } + List out = new ArrayList<>(); + JsonNode items = om.readTree(body).at("/response/body/items/item"); - try { - List out = new ArrayList<>(); - JsonNode items = om.readTree(trimmed).at("/response/body/items/item"); if (items.isArray()) { + log.info("🔎 연관관광지 {}건 placeId={}", items.size(), place.getPlaceId()); + for (JsonNode it : items) { - out.add(new RelatedPlaceItemDto( - it.path("rlteTatsCd").asText(null), - it.path("rlteTatsNm").asText(null) + String name = it.path("rlteTatsNm").asText(null); + String regnCd = it.path("rlteRegnCd").asText(null); + String signgu = it.path("rlteSignguCd").asText(null); + + Double distance = null; + double[] coords = fetchCoordsByKeyword(name, regnCd, signgu); + if (coords != null) { + distance = haversine( + place.getLatitude(), place.getLongitude(), + coords[0], coords[1] + ); + log.info("✅ 거리계산 성공 from={} → to={} distance={}m", + place.getNameKo(), name, distance.intValue()); + } else { + log.warn("⚠️ 좌표조회 실패 keyword={} (encoded={}), areaCd={}, signguCd={}, 응답={}", + name, encodedKeyword, areaCd, sigunguCd, safeHead(body, 200)); + } + + out.add(new RelatedResponseDto( + name, + it.path("rlteRegnNm").asText(null), + it.path("rlteSignguNm").asText(null), + it.path("rlteCtgryLclsNm").asText(null), + it.path("rlteCtgryMclsNm").asText(null), + it.path("rlteCtgrySclsNm").asText(null), + distance )); } - } else if (!items.isMissingNode()) { - out.add(new RelatedPlaceItemDto( - items.path("rlteTatsCd").asText(null), - items.path("rlteTatsNm").asText(null) - )); + } else { + log.warn("⚠️ 연관관광지 없음 placeId={}, bodyHead={}", + place.getPlaceId(), safeHead(body, 200)); } - log.info("✔ TourAPI 파싱 성공 keyword={}, 결과 {}건", keyword, out.size()); + return out; + } catch (Exception e) { - log.error("❌ TourAPI JSON 파싱 실패 keyword={}, bodyHead={}", keyword, safeHead(trimmed, 200), e); + log.error("❌ TourAPI fetchRelatedPlacesInfo 실패 placeId={}, uri={}", place.getPlaceId(), uri, e); return List.of(); } } - // 2) 다국어 detailCommon2 (Kor/Eng/Jpn/ChsService2 하위) - private DetailCommonItemDto fetchDetailCommon(String baseUrl, String contentId) { + // 연관관광지명으로 TourAPI(KorService2/searchKeyword2)에서 좌표(mapx/mapy)를 조회 + // - 우선 areaCode+sigunguCode로 시도 + // - 실패 시 areaCode만 + // - 그래도 실패하면 keyword만 + // @return [위도(lat), 경도(lon)] or null + private double[] fetchCoordsByKeyword(String keyword, String areaCd, String signguCd) { + // 1차: area + sigungu + double[] coords = trySearchKeyword2(keyword, areaCd, signguCd); + if (coords != null) { + log.info("✅ 좌표조회 성공 (정밀검색) keyword={}, lat={}, lon={}", keyword, coords[0], coords[1]); + return coords; + } + + // 2차: area만 + coords = trySearchKeyword2(keyword, areaCd, null); + if (coords != null) { + log.info("✅ 좌표조회 성공 (시도 검색) keyword={}, lat={}, lon={}", keyword, coords[0], coords[1]); + return coords; + } + + // 3차: keyword만 + coords = trySearchKeyword2(keyword, null, null); + if (coords != null) { + log.info("✅ 좌표조회 성공 (전국 검색) keyword={}, lat={}, lon={}", keyword, coords[0], coords[1]); + return coords; + } + + log.warn("❌ 좌표조회 실패 keyword={}", keyword); + return null; + } + // 실제로 TourAPI /searchKeyword2 호출을 수행하는 헬퍼 메서드 + private double[] trySearchKeyword2(String keyword, String areaCd, String signguCd) { String encodedKey = URLEncoder.encode(serviceKey, StandardCharsets.UTF_8); + String encodedKeyword = URLEncoder.encode(keyword, StandardCharsets.UTF_8); - URI uri = UriComponentsBuilder - .fromUriString(baseUrl) - .path("/detailCommon2") + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(korBase) + .path("/searchKeyword2") .queryParam("serviceKey", encodedKey) .queryParam("MobileOS", mobileOs) .queryParam("MobileApp", mobileApp) .queryParam("_type", "json") - .queryParam("contentId", contentId) - .queryParam("defaultYN", "Y") - .queryParam("firstImageYN", "Y") - .queryParam("areacodeYN", "Y") - .queryParam("addrinfoYN", "Y") - .queryParam("mapinfoYN", "Y") - .queryParam("overviewYN", "Y") - .build(true) - .toUri(); - - log.info("▶ TourAPI(detailCommon2) 호출: {}", uri); - - String body = null; + .queryParam("keyword", encodedKeyword); + + if (areaCd != null) builder.queryParam("areaCode", areaCd); + if (signguCd != null) builder.queryParam("sigunguCode", signguCd); + + URI uri = builder.build(true).toUri(); + try { - body = RestClient.builder().baseUrl("") + String body = RestClient.builder().baseUrl("") .build() .get() .uri(uri) .accept(MediaType.APPLICATION_JSON) .retrieve() .body(String.class); - log.info("◀ TourAPI 응답(앞 300자): {}", safeHead(body, 300)); - } catch (Exception e) { - log.error("❌ TourAPI 호출 실패 detailCommon2 contentId={}, uri={}", contentId, uri, e); - return null; - } - if (body == null || body.isBlank()) { - log.error("❌ TourAPI 빈 응답 detailCommon2 contentId={}", contentId); - return null; - } + log.debug("📡 searchKeyword2 호출 keyword={}, uri={}, bodyHead={}", keyword, uri, safeHead(body, 200)); - String trimmed = body.trim(); - if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { - // XML 에러 응답 처리 - String resultMsg = null, authMsg = null; - try { - Matcher m1 = Pattern.compile("(.*?)").matcher(trimmed); - if (m1.find()) resultMsg = m1.group(1); - Matcher m2 = Pattern.compile("(.*?)").matcher(trimmed); - if (m2.find()) authMsg = m2.group(1); - } catch (Exception ignore) {} - log.error("❌ TourAPI XML 에러 detailCommon2 contentId={}, resultMsg={}, returnAuthMsg={}, head={}", - contentId, resultMsg, authMsg, safeHead(trimmed, 200)); - return null; - } + JsonNode items = om.readTree(body).at("/response/body/items/item"); - try { - JsonNode item = om.readTree(trimmed).at("/response/body/items/item"); - if (item == null || item.isMissingNode() || item.isNull()) { - log.warn("⚠ TourAPI detailCommon2 contentId={} 결과 없음", contentId); - return null; + if (items.isArray() && items.size() > 0) { + JsonNode first = items.get(0); + double lon = first.path("mapx").asDouble(); + double lat = first.path("mapy").asDouble(); + return new double[]{lat, lon}; + } else if (!items.isMissingNode()) { + double lon = items.path("mapx").asDouble(); + double lat = items.path("mapy").asDouble(); + return new double[]{lat, lon}; } - - DetailCommonItemDto dto = DetailCommonItemDto.builder() - .contentId(item.path("contentid").asText(null)) - .title(item.path("title").asText(null)) - .overview(item.path("overview").asText(null)) - .addr(item.path("addr1").asText(null)) - .mapx(item.path("mapx").asText(null)) - .mapy(item.path("mapy").asText(null)) - .areaCode(item.path("areacode").asText(null)) - .siGunGuCode(item.path("siGungu").asText(null)) - .image(item.path("firstimage").asText(null)) - .build(); - - log.info("✔ TourAPI 파싱 성공 detailCommon2 contentId={}", contentId); - return dto; } catch (Exception e) { - log.error("❌ TourAPI JSON 파싱 실패 detailCommon2 contentId={}, bodyHead={}", - contentId, safeHead(trimmed, 200), e); - return null; + log.error("❌ TourAPI searchKeyword2 호출 실패 keyword={}, uri={}", keyword, uri, e); } + return null; + } + + private String safeHead(String body, int max) { + if (body == null) return "null"; + String t = body.trim(); + return t.substring(0, Math.min(max, t.length())); } - public DetailCommonItemDto fetchKorDetailCommon(String contentId) { return fetchDetailCommon(korBase, contentId); } - public DetailCommonItemDto fetchEngDetailCommon(String contentId) { return fetchDetailCommon(engBase, contentId); } - public DetailCommonItemDto fetchJpnDetailCommon(String contentId) { return fetchDetailCommon(jpnBase, contentId); } - public DetailCommonItemDto fetchChsDetailCommon(String contentId) { return fetchDetailCommon(chsBase, contentId); } + private double haversine(double lat1, double lon1, double lat2, double lon2) { + final int R = 6371000; // Earth radius in meters + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } } diff --git a/src/main/java/com/mey/backend/domain/place/service/RegionResolver.java b/src/main/java/com/mey/backend/domain/place/service/RegionResolver.java deleted file mode 100644 index 49447b9..0000000 --- a/src/main/java/com/mey/backend/domain/place/service/RegionResolver.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.mey.backend.domain.place.service; - -import com.mey.backend.domain.place.dto.DetailCommonItemDto; -import com.mey.backend.domain.region.entity.Region; -import com.mey.backend.domain.region.repository.RegionRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class RegionResolver { - - private final RegionRepository regionRepository; - - // areaCode -> Region.nameKo 매핑 테이블 - private static final Map AREA_CODE_TO_REGION = Map.ofEntries( - Map.entry(11, "서울"), - Map.entry(26, "부산"), - Map.entry(27, "대구"), - Map.entry(28, "인천"), - Map.entry(29, "광주"), - Map.entry(30, "대전"), - Map.entry(31, "울산"), - Map.entry(36, "세종"), - Map.entry(41, "경기"), - Map.entry(43, "충북"), - Map.entry(44, "충남"), - Map.entry(46, "전남"), - Map.entry(47, "경북"), - Map.entry(48, "경남"), - Map.entry(50, "제주"), - Map.entry(51, "강원"), - Map.entry(52, "전북") - ); - - public Region resolve(DetailCommonItemDto kor) { - try { - int areaCode = Integer.parseInt(kor.getAreaCode()); - String regionName = AREA_CODE_TO_REGION.get(areaCode); - - if (regionName == null) { - throw new IllegalStateException("Unknown areaCode=" + areaCode); - } - - return regionRepository.findByNameKo(regionName) - .orElseThrow(() -> new IllegalStateException("Region not found in DB: " + regionName)); - } catch (Exception e) { - throw new IllegalStateException("RegionResolver failed for contentId=" + kor.getContentId(), e); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/region/repository/RegionRepository.java b/src/main/java/com/mey/backend/domain/region/repository/RegionRepository.java index 659c910..2b619ad 100644 --- a/src/main/java/com/mey/backend/domain/region/repository/RegionRepository.java +++ b/src/main/java/com/mey/backend/domain/region/repository/RegionRepository.java @@ -7,5 +7,4 @@ public interface RegionRepository extends JpaRepository { - Optional findByNameKo(String nameKo); } \ No newline at end of file From 16eef73a6aaf9885484b3200f67f96c9e50009c8 Mon Sep 17 00:00:00 2001 From: sumi Date: Thu, 11 Sep 2025 17:33:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=97=B0=EA=B4=80=EA=B4=80?= =?UTF-8?q?=EA=B4=91=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?locationBasedList2=20=EA=B8=B0=EB=B0=98=20+=20=EB=8B=A4?= =?UTF-8?q?=EA=B5=AD=EC=96=B4=C2=B7=ED=83=80=EC=9E=85=EB=AA=85=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 TarRlteTarService1/searchKeyword1 + KorService2/searchKeyword2 + Haversine 거리계산 로직 제거 - TourAPI base URL 분기 (Kor/Eng/Jpn/Chs) → language 파라미터 기반 다국어 지원 - locationBasedList2 호출로 거리·주소·좌표·대표이미지·제목·전화번호 실시간 조회 - contentTypeId → 관광타입명(K/E/J/C) 매핑 로직 추가 - RelatedResponseDto에서 contentTypeId 필드를 contentTypeName으로 변경 --- .../place/controller/PlaceController.java | 6 +- .../domain/place/dto/RelatedResponseDto.java | 16 +- .../domain/place/service/PlaceService.java | 11 +- .../place/service/PlaceTourApiClient.java | 244 +++++------------- 4 files changed, 70 insertions(+), 207 deletions(-) diff --git a/src/main/java/com/mey/backend/domain/place/controller/PlaceController.java b/src/main/java/com/mey/backend/domain/place/controller/PlaceController.java index 7a219bf..6a7a825 100644 --- a/src/main/java/com/mey/backend/domain/place/controller/PlaceController.java +++ b/src/main/java/com/mey/backend/domain/place/controller/PlaceController.java @@ -43,10 +43,10 @@ public PlaceResponseDto getPlaceDetail(@PathVariable Long placeId) { summary = "장소 ID로 연계 관광지 정보 조회", description = "장소 ID로 연계 관광지 정보를 조회한 결과를 반환합니다." ) - @GetMapping("/{placeId}/related") - public List getRelatedPlaces(@PathVariable Long placeId) { + @GetMapping("/{placeId}/related/{language}") + public List getRelatedPlaces(@PathVariable Long placeId, @PathVariable String language) { - return placeService.getRelatedPlaces(placeId); + return placeService.getRelatedPlaces(placeId, language); } @Operation( diff --git a/src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java b/src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java index 8dcceb0..4cea32f 100644 --- a/src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java +++ b/src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java @@ -7,12 +7,10 @@ @AllArgsConstructor @NoArgsConstructor public class RelatedResponseDto { - - private String rlteTatsNm; // 연관관광지명 - private String rlteRegnNm; // 연관관광지 시도명 - private String rlteSignguNm; // 연관관광지 시군구명 - private String rlteCtgryLclsNm; // 대분류 - private String rlteCtgryMclsNm; // 중분류 - private String rlteCtgrySclsNm; // 소분류 - private Double distanceMeter; // 출발지→연관관광지 직선거리(m) -} + private String title; + private String address; + private String tel; + private String firstImage; + private String contentTypeName; + private double distance; +} \ No newline at end of file diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceService.java b/src/main/java/com/mey/backend/domain/place/service/PlaceService.java index 9a10e80..e15b167 100644 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceService.java +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceService.java @@ -40,18 +40,11 @@ public PlaceResponseDto getPlaceDetail(Long placeId) { return new PlaceResponseDto(place); } - public List getRelatedPlaces(Long placeId) { + public List getRelatedPlaces(Long placeId, String language) { Place place = placeRepository.findPlaceByPlaceId(placeId); - String[] getAreaCdSiGunGuCd = tourApiClient.fetchRegionCodesByLocation(place.getLatitude(), place.getLongitude()); - - return tourApiClient.fetchRelatedPlacesInfo( - place, - "202508", - getAreaCdSiGunGuCd[0], - getAreaCdSiGunGuCd[1] - ); + return tourApiClient.fetchRelatedPlaces(place.getLatitude(), place.getLongitude(), language); } @Transactional(readOnly = true) diff --git a/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java b/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java index cd2e51c..e1dee30 100644 --- a/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java @@ -17,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Map; @Slf4j @Component @@ -31,8 +32,6 @@ public class PlaceTourApiClient { private String mobileOs; @Value("${tourapi.mobile-app}") private String mobileApp; - @Value("${tourapi.base.related}") - private String relatedBase; @Value("${tourapi.base.kor}") private String korBase; @Value("${tourapi.base.eng}") @@ -42,13 +41,37 @@ public class PlaceTourApiClient { @Value("${tourapi.base.chs}") private String chsBase; + public List fetchRelatedPlaces(double latitude, double longitude, String language) { - // TourAPI locationBasedList2 호출해서 areaCode, sigunguCode 반환 - public String[] fetchRegionCodesByLocation(double latitude, double longitude) { String encodedKey = URLEncoder.encode(serviceKey, StandardCharsets.UTF_8); + String uriBase; + String langCode; + + switch (language) { + + case "E": + uriBase = engBase; + langCode = "E"; + break; + + case "J": + uriBase = jpnBase; + langCode = "J"; + break; + + case "C": + uriBase = chsBase; + langCode = "C"; + break; + + default : + uriBase = korBase; + langCode = "K"; + break; + } URI uri = UriComponentsBuilder - .fromUriString(korBase) + .fromUriString(uriBase) .path("/locationBasedList2") .queryParam("serviceKey", encodedKey) .queryParam("MobileOS", mobileOs) @@ -56,8 +79,9 @@ public String[] fetchRegionCodesByLocation(double latitude, double longitude) { .queryParam("_type", "json") .queryParam("mapX", longitude) // 경도 .queryParam("mapY", latitude) // 위도 - .queryParam("radius", 700) // 1000m 반경 - .queryParam("numOfRows", 1) // 한 건만 조회 + .queryParam("radius", 500) // 500m 반경 + .queryParam("arrange", "E") // 거리순 정렬 + .queryParam("numOfRows", 10) .queryParam("pageNo", 1) .build(true).toUri(); @@ -70,202 +94,50 @@ public String[] fetchRegionCodesByLocation(double latitude, double longitude) { .retrieve() .body(String.class); - JsonNode items = om.readTree(body).at("/response/body/items/item"); - - JsonNode target = items.isArray() && items.size() > 0 ? items.get(0) : items; - - if (target != null && !target.isMissingNode()) { - String areaCode = target.path("lDongRegnCd").asText(null); - String sigunguCode = target.path("lDongSignguCd").asText(null); - - if (Integer.parseInt(areaCode) > 0) { - log.info("✅ 좌표→행정코드 변환 성공 lat={}, lon={}, area={}, sigungu={}", - latitude, longitude, areaCode, sigunguCode); - return new String[]{areaCode, sigunguCode}; - } else { - log.warn("⚠️ 지역코드 없음 lat={}, lon={}, raw={}", latitude, longitude, target.toPrettyString()); - } - } else { - log.warn("⚠️ TourAPI locationBasedList2 결과 없음 lat={}, lon={}", latitude, longitude); - } - } catch (Exception e) { - log.error("❌ TourAPI locationBasedList2 호출 실패 lat={}, lon={}", latitude, longitude, e); - } - return null; - } - - public List fetchRelatedPlacesInfo( - Place place, String baseYm, String areaCd, String sigunguCd) { - - String encodedKey = URLEncoder.encode(serviceKey, StandardCharsets.UTF_8); - String encodedKeyword = URLEncoder.encode(place.getNameKo(), StandardCharsets.UTF_8); - String fullSigunguCd = areaCd+ sigunguCd; - URI uri = UriComponentsBuilder - .fromUriString(relatedBase) - .path("/searchKeyword1") - .queryParam("serviceKey", encodedKey) - .queryParam("MobileOS", mobileOs) - .queryParam("MobileApp", mobileApp) - .queryParam("_type", "json") - .queryParam("baseYm", baseYm) - .queryParam("areaCd", areaCd) - .queryParam("signguCd", fullSigunguCd) - .queryParam("keyword", encodedKeyword) - .build(true).toUri(); - - try { - String body = RestClient.builder().baseUrl("") - .build() - .get() - .uri(uri) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .body(String.class); - - log.info("📡 TourAPI searchKeyword1 호출 placeId={}, uri={}", place.getNameKo(), uri); - log.debug("📡 searchKeyword1 응답 bodyHead={}", safeHead(body, 200)); - List out = new ArrayList<>(); JsonNode items = om.readTree(body).at("/response/body/items/item"); if (items.isArray()) { - log.info("🔎 연관관광지 {}건 placeId={}", items.size(), place.getPlaceId()); + log.info("📍 locationBasedList2 {}건 lat={}, lon={}", items.size(), latitude, longitude); for (JsonNode it : items) { - String name = it.path("rlteTatsNm").asText(null); - String regnCd = it.path("rlteRegnCd").asText(null); - String signgu = it.path("rlteSignguCd").asText(null); - - Double distance = null; - double[] coords = fetchCoordsByKeyword(name, regnCd, signgu); - if (coords != null) { - distance = haversine( - place.getLatitude(), place.getLongitude(), - coords[0], coords[1] - ); - log.info("✅ 거리계산 성공 from={} → to={} distance={}m", - place.getNameKo(), name, distance.intValue()); - } else { - log.warn("⚠️ 좌표조회 실패 keyword={} (encoded={}), areaCd={}, signguCd={}, 응답={}", - name, encodedKeyword, areaCd, sigunguCd, safeHead(body, 200)); - } + String address = it.path("addr1").asText(""); + int typeId = it.path("contenttypeid").asInt(0); + String typeName = getName(typeId, langCode); out.add(new RelatedResponseDto( - name, - it.path("rlteRegnNm").asText(null), - it.path("rlteSignguNm").asText(null), - it.path("rlteCtgryLclsNm").asText(null), - it.path("rlteCtgryMclsNm").asText(null), - it.path("rlteCtgrySclsNm").asText(null), - distance + it.path("title").asText(null), // 제목 + address, // 주소 + it.path("tel").asText(null), // 전화번호 + it.path("firstimage").asText(null), // 대표 이미지 + typeName, // 관광타입명 + it.path("dist").asDouble(0.0) // 거리 )); } } else { - log.warn("⚠️ 연관관광지 없음 placeId={}, bodyHead={}", - place.getPlaceId(), safeHead(body, 200)); + log.warn("⚠️ locationBasedList2 결과 없음 lat={}, lon={}", latitude, longitude); } return out; - } catch (Exception e) { - log.error("❌ TourAPI fetchRelatedPlacesInfo 실패 placeId={}, uri={}", place.getPlaceId(), uri, e); - return List.of(); + log.error("❌ locationBasedList2 호출 실패 lat={}, lon={}", latitude, longitude, e); } + return List.of(); } - // 연관관광지명으로 TourAPI(KorService2/searchKeyword2)에서 좌표(mapx/mapy)를 조회 - // - 우선 areaCode+sigunguCode로 시도 - // - 실패 시 areaCode만 - // - 그래도 실패하면 keyword만 - // @return [위도(lat), 경도(lon)] or null - private double[] fetchCoordsByKeyword(String keyword, String areaCd, String signguCd) { - // 1차: area + sigungu - double[] coords = trySearchKeyword2(keyword, areaCd, signguCd); - if (coords != null) { - log.info("✅ 좌표조회 성공 (정밀검색) keyword={}, lat={}, lon={}", keyword, coords[0], coords[1]); - return coords; - } - - // 2차: area만 - coords = trySearchKeyword2(keyword, areaCd, null); - if (coords != null) { - log.info("✅ 좌표조회 성공 (시도 검색) keyword={}, lat={}, lon={}", keyword, coords[0], coords[1]); - return coords; - } - - // 3차: keyword만 - coords = trySearchKeyword2(keyword, null, null); - if (coords != null) { - log.info("✅ 좌표조회 성공 (전국 검색) keyword={}, lat={}, lon={}", keyword, coords[0], coords[1]); - return coords; - } - - log.warn("❌ 좌표조회 실패 keyword={}", keyword); - return null; - } - - // 실제로 TourAPI /searchKeyword2 호출을 수행하는 헬퍼 메서드 - private double[] trySearchKeyword2(String keyword, String areaCd, String signguCd) { - String encodedKey = URLEncoder.encode(serviceKey, StandardCharsets.UTF_8); - String encodedKeyword = URLEncoder.encode(keyword, StandardCharsets.UTF_8); - - UriComponentsBuilder builder = UriComponentsBuilder - .fromUriString(korBase) - .path("/searchKeyword2") - .queryParam("serviceKey", encodedKey) - .queryParam("MobileOS", mobileOs) - .queryParam("MobileApp", mobileApp) - .queryParam("_type", "json") - .queryParam("keyword", encodedKeyword); - - if (areaCd != null) builder.queryParam("areaCode", areaCd); - if (signguCd != null) builder.queryParam("sigunguCode", signguCd); - - URI uri = builder.build(true).toUri(); - - try { - String body = RestClient.builder().baseUrl("") - .build() - .get() - .uri(uri) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .body(String.class); - - log.debug("📡 searchKeyword2 호출 keyword={}, uri={}, bodyHead={}", keyword, uri, safeHead(body, 200)); - - JsonNode items = om.readTree(body).at("/response/body/items/item"); - - if (items.isArray() && items.size() > 0) { - JsonNode first = items.get(0); - double lon = first.path("mapx").asDouble(); - double lat = first.path("mapy").asDouble(); - return new double[]{lat, lon}; - } else if (!items.isMissingNode()) { - double lon = items.path("mapx").asDouble(); - double lat = items.path("mapy").asDouble(); - return new double[]{lat, lon}; - } - } catch (Exception e) { - log.error("❌ TourAPI searchKeyword2 호출 실패 keyword={}, uri={}", keyword, uri, e); - } - return null; - } - - private String safeHead(String body, int max) { - if (body == null) return "null"; - String t = body.trim(); - return t.substring(0, Math.min(max, t.length())); - } - - private double haversine(double lat1, double lon1, double lat2, double lon2) { - final int R = 6371000; // Earth radius in meters - double dLat = Math.toRadians(lat2 - lat1); - double dLon = Math.toRadians(lon2 - lon1); - double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) - + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) - * Math.sin(dLon / 2) * Math.sin(dLon / 2); - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; + private static final Map> CONTENT_TYPE_MAP = Map.of( + 12, Map.of("K", "관광지", "E", "Tourist Attraction", "J", "観光地", "C", "旅游景点"), + 14, Map.of("K", "문화시설", "E", "Cultural Facility", "J", "文化施設", "C", "文化设施"), + 15, Map.of("K", "축제/공연/행사", "E", "Festival/Performance/Event", "J", "祭り/公演/イベント", "C", "节日/演出/活动"), + 25, Map.of("K", "여행코스", "E", "Travel Course", "J", "旅行コース", "C", "旅行路线"), + 28, Map.of("K", "레포츠", "E", "Leisure Sports", "J", "レジャースポーツ", "C", "休闲运动"), + 32, Map.of("K", "숙박", "E", "Accommodation", "J", "宿泊", "C", "住宿"), + 38, Map.of("K", "쇼핑", "E", "Shopping", "J", "ショッピング", "C", "购物"), + 39, Map.of("K", "음식점", "E", "Restaurant", "J", "飲食店", "C", "餐厅") + ); + + public static String getName(int typeId, String langCode) { + return CONTENT_TYPE_MAP.getOrDefault(typeId, Map.of()) + .getOrDefault(langCode, "Unknown"); } }