diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 030dd9e..817ffd2 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -14,6 +14,7 @@ env: TMAP_APP_KEY: ${{ secrets.TMAP_APP_KEY }} S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + TOURAPI_SERVICE_KEY: ${{ secrets.TOURAPI_SERVICE_KEY }} jobs: build: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2be8b8..7214bc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: TMAP_APP_KEY: ${{ secrets.TMAP_APP_KEY }} S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + TOURAPI_SERVICE_KEY: ${{ secrets.TOURAPI_SERVICE_KEY }} steps: - uses: actions/checkout@v2 diff --git a/src/main/java/com/mey/backend/BackendApplication.java b/src/main/java/com/mey/backend/BackendApplication.java index 2f9f817..52bd6b9 100644 --- a/src/main/java/com/mey/backend/BackendApplication.java +++ b/src/main/java/com/mey/backend/BackendApplication.java @@ -1,15 +1,15 @@ -package com.mey.backend; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; - -@EnableJpaAuditing -@SpringBootApplication -public class BackendApplication { - - public static void main(String[] args) { - SpringApplication.run(BackendApplication.class, args); - } - -} +package com.mey.backend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class BackendApplication { + + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } + +} diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/ChatResponseBuilder.java b/src/main/java/com/mey/backend/domain/chatbot/service/ChatResponseBuilder.java index a68d62e..88b5352 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/ChatResponseBuilder.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/ChatResponseBuilder.java @@ -91,7 +91,7 @@ public ChatResponse createPlaceInfoResponse(String message, List places, .placeId(place.getPlaceId()) .name(place.getNameKo()) .description(place.getDescriptionKo()) - .address(place.getAddress()) + .address(place.getAddressKo()) .themes(place.getThemes()) .costInfo(place.getCostInfo()) .build()) diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java b/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java index 8547128..c7fd835 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/ChatService.java @@ -275,7 +275,7 @@ private String createDocumentFromPlace(Place place) { StringBuilder document = new StringBuilder(); document.append("장소명: ").append(place.getNameKo()).append("\n"); document.append("설명: ").append(place.getDescriptionKo()).append("\n"); - document.append("주소: ").append(place.getAddress()).append("\n"); + document.append("주소: ").append(place.getAddressKo()).append("\n"); document.append("지역: ").append(place.getRegion().getNameKo()).append("\n"); document.append("테마: ").append(String.join(", ", place.getThemes())).append("\n"); document.append("비용정보: ").append(place.getCostInfo()).append("\n"); diff --git a/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java b/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java index 471bde5..94857dc 100644 --- a/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java +++ b/src/main/java/com/mey/backend/domain/chatbot/service/RagService.java @@ -263,7 +263,7 @@ public String generateRouteRecommendationAnswerWithPlaces(String question, java. """, place.getNameKo(), place.getDescriptionKo(), - place.getAddress(), + place.getAddressKo(), place.getRegion().getNameKo(), String.join(", ", place.getThemes()), place.getCostInfo(), 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..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 @@ -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/{language}") + public List getRelatedPlaces(@PathVariable Long placeId, @PathVariable String language) { + + return placeService.getRelatedPlaces(placeId, language); + } + @Operation( summary = "인기 장소 조회", description = "인기 장소 리스트를 조회한 결과를 반환합니다." 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 d436644..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 @@ -1,56 +1,56 @@ -package com.mey.backend.domain.place.dto; - -import com.mey.backend.domain.place.entity.Place; -import lombok.Getter; - -import java.util.List; -import java.util.Map; - -@Getter -public class PlaceResponseDto { - - private Long id; - private Long regionId; - - private String nameKo; - private String nameEn; - - private String descriptionKo; - private String descriptionEn; - - private Double longitude; - private Double latitude; - - private String imageUrl; - private String address; - private String contactInfo; - private String websiteUrl; - - private String kakaoPlaceId; - private String tourApiPlaceId; - - private Map openingHours; - private List themes; - - private String costInfo; - - public PlaceResponseDto(Place place) { - this.id = place.getPlaceId(); - this.regionId = place.getRegion() != null ? place.getRegion().getRegionId() : null; - this.nameKo = place.getNameKo(); - this.nameEn = place.getNameEn(); - this.descriptionKo = place.getDescriptionKo(); - this.descriptionEn = place.getDescriptionEn(); - this.longitude = place.getLongitude(); - this.latitude = place.getLatitude(); - this.imageUrl = place.getImageUrl(); - this.address = place.getAddress(); - this.contactInfo = place.getContactInfo(); - this.websiteUrl = place.getWebsiteUrl(); - this.kakaoPlaceId = place.getKakaoPlaceId(); - this.tourApiPlaceId = place.getTourApiPlaceId(); - this.openingHours = place.getOpeningHours(); - this.themes = place.getThemes(); - this.costInfo = place.getCostInfo(); - } +package com.mey.backend.domain.place.dto; + +import com.mey.backend.domain.place.entity.Place; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +public class PlaceResponseDto { + + private Long id; + private Long regionId; + + private String nameKo; + private String nameEn; + + private String descriptionKo; + private String descriptionEn; + + private Double longitude; + private Double latitude; + + private String imageUrl; + private String address; + private String contactInfo; + private String websiteUrl; + + private String kakaoPlaceId; + private String tourApiPlaceId; + + private Map openingHours; + private List themes; + + private String costInfo; + + public PlaceResponseDto(Place place) { + this.id = place.getPlaceId(); + this.regionId = place.getRegion() != null ? place.getRegion().getRegionId() : null; + this.nameKo = place.getNameKo(); + this.nameEn = place.getNameEn(); + this.descriptionKo = place.getDescriptionKo(); + this.descriptionEn = place.getDescriptionEn(); + this.longitude = place.getLongitude(); + this.latitude = place.getLatitude(); + this.imageUrl = place.getImageUrl(); + this.address = place.getAddressKo(); + this.contactInfo = place.getContactInfo(); + this.websiteUrl = place.getWebsiteUrl(); + this.kakaoPlaceId = place.getKakaoPlaceId(); + this.tourApiPlaceId = place.getTourApiPlaceId(); + this.openingHours = place.getOpeningHours(); + this.themes = place.getThemes(); + this.costInfo = place.getCostInfo(); + } } \ No newline at end of file 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..4cea32f --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/dto/RelatedResponseDto.java @@ -0,0 +1,16 @@ +package com.mey.backend.domain.place.dto; + +import lombok.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class RelatedResponseDto { + 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/entity/Place.java b/src/main/java/com/mey/backend/domain/place/entity/Place.java index 1bb87e4..2fd5b4b 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 @@ -1,71 +1,93 @@ -package com.mey.backend.domain.place.entity; - -import com.mey.backend.domain.common.entity.BaseTimeEntity; -import com.mey.backend.domain.region.entity.Region; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; -import java.util.List; -import java.util.Map; - -@Entity -@Table(name = "places") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class Place extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long placeId; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "region_id", nullable = false) - private Region region; - - @Column(nullable = false) - private String nameKo; - - @Column(nullable = false) - private String nameEn; - - @Column(nullable = false, columnDefinition = "TEXT") - private String descriptionKo; - - @Column(nullable = false, columnDefinition = "TEXT") - private String descriptionEn; - - @Column(nullable = false) - private Double longitude; - - @Column(nullable = false) - private Double latitude; - - @Column(nullable = false) - private String imageUrl; - - @Column(nullable = false) - private String address; - - private String contactInfo; - - private String websiteUrl; - - private String kakaoPlaceId; - - private String tourApiPlaceId; - - @JdbcTypeCode(SqlTypes.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 costInfo; -} +package com.mey.backend.domain.place.entity; + +import com.mey.backend.domain.common.entity.BaseTimeEntity; +import com.mey.backend.domain.region.entity.Region; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import java.util.List; +import java.util.Map; + +@Entity +@Table(name = "places") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Place extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long placeId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "region_id", nullable = false) + private Region region; + + @Column(nullable = false) + private String nameKo; + + @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; + + @Column(nullable = false) + private Double latitude; + + @Column(nullable = false) + private String imageUrl; + + @Column(nullable = false) + private String addressKo; + +// @Column(nullable = false) +// private String addressEn; +// +// @Column(nullable = false) +// private String addressJp; +// +// @Column(nullable = false) +// private String addressCh; + + private String contactInfo; + + private String websiteUrl; + + private String kakaoPlaceId; + + @Column(unique = true) + private String tourApiPlaceId; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "json") + private Map openingHours; // 예: "monday": "09:00-18:00" + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "json") + private List themes; + + private String costInfo; +} 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 3e2eb9d..584411e 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 @@ -7,7 +7,6 @@ import org.springframework.data.repository.query.Param; import java.util.List; -import java.util.Optional; public interface PlaceRepository extends JpaRepository { @@ -20,8 +19,7 @@ public interface PlaceRepository extends JpaRepository { @Query("SELECT p FROM Place p WHERE MOD(p.placeId, 2) = 1") List findOddIdPlaces(Pageable pageable); - Optional findByNameKo(String nameKo); - + Place findPlaceByPlaceId(Long placeId); @Query(value = "SELECT COUNT(*) FROM places p JOIN regions r ON p.region_id = r.region_id WHERE JSON_CONTAINS(p.themes, :themeJson) AND r.name_ko = :regionName", nativeQuery = true) int countByThemeAndRegion(@Param("themeJson") String themeJson, @Param("regionName") String regionName); } 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 9f39118..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 @@ -1,67 +1,74 @@ -package com.mey.backend.domain.place.service; - -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.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; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class PlaceService { - - private final PlaceRepository placeRepository; - private final UserLikePlaceRepository userLikePlaceRepository; - - public List searchPlaces(String keyword) { - - return placeRepository - .findByNameKoContainingIgnoreCaseOrNameEnContainingIgnoreCase(keyword, keyword) - .stream() - .map(PlaceSimpleResponseDto::new) - .collect(Collectors.toList()); - } - - public PlaceResponseDto getPlaceDetail(Long placeId) { - Place place = placeRepository.findById(placeId) - .orElseThrow(() -> new PlaceException(ErrorStatus.PLACE_NOT_FOUND)); - return new PlaceResponseDto(place); - } - - @Transactional(readOnly = true) - public List getPopularPlaces(Integer limit) { - int n = (limit == null || limit <= 0) ? 10 : limit; - - // placeId 오름차순으로 홀수 ID만 2n개 정도 가져오기 - Pageable pageable = PageRequest.of(0, 2 * n, Sort.by(Sort.Direction.ASC, "placeId")); - List places = placeRepository.findOddIdPlaces(pageable); - - // 최종 반환은 최대 n개까지만 보장 - return places.stream() - .limit(n) - .map(PlaceResponseDto::new) - .toList(); - } - - public List getPlacesByTheme(String keyword, int limit) { - // DB 저장 형식에 맞추어 정규화 (예: 전부 대문자 + 언더스코어) - String norm = keyword.toUpperCase(); // "K_POP", "K_DRAMA" 등 - - List places = placeRepository.findByThemeKeywordWithLimit(norm, limit); - if (places.isEmpty()) throw new PlaceException(ErrorStatus.PLACE_NOT_FOUND); - - return places.stream().map(PlaceThemeResponseDto::new).collect(Collectors.toList()); - } -} +package com.mey.backend.domain.place.service; + +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.global.exception.PlaceException; +import com.mey.backend.global.payload.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PlaceService { + + private final PlaceRepository placeRepository; + private final PlaceTourApiClient tourApiClient; + + public List searchPlaces(String keyword) { + + return placeRepository + .findByNameKoContainingIgnoreCaseOrNameEnContainingIgnoreCase(keyword, keyword) + .stream() + .map(PlaceSimpleResponseDto::new) + .collect(Collectors.toList()); + } + + public PlaceResponseDto getPlaceDetail(Long placeId) { + Place place = placeRepository.findById(placeId) + .orElseThrow(() -> new PlaceException(ErrorStatus.PLACE_NOT_FOUND)); + return new PlaceResponseDto(place); + } + + public List getRelatedPlaces(Long placeId, String language) { + + Place place = placeRepository.findPlaceByPlaceId(placeId); + + return tourApiClient.fetchRelatedPlaces(place.getLatitude(), place.getLongitude(), language); + } + + @Transactional(readOnly = true) + public List getPopularPlaces(Integer limit) { + int n = (limit == null || limit <= 0) ? 10 : limit; + + // placeId 오름차순으로 홀수 ID만 2n개 정도 가져오기 + Pageable pageable = PageRequest.of(0, 2 * n, Sort.by(Sort.Direction.ASC, "placeId")); + List places = placeRepository.findOddIdPlaces(pageable); + + // 최종 반환은 최대 n개까지만 보장 + return places.stream() + .limit(n) + .map(PlaceResponseDto::new) + .toList(); + } + + public List getPlacesByTheme(String keyword, int limit) { + // DB 저장 형식에 맞추어 정규화 (예: 전부 대문자 + 언더스코어) + String norm = keyword.toUpperCase(); // "K_POP", "K_DRAMA" 등 + + List places = placeRepository.findByThemeKeywordWithLimit(norm, limit); + if (places.isEmpty()) throw new PlaceException(ErrorStatus.PLACE_NOT_FOUND); + + return places.stream().map(PlaceThemeResponseDto::new).collect(Collectors.toList()); + } +} \ 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..e1dee30 --- /dev/null +++ b/src/main/java/com/mey/backend/domain/place/service/PlaceTourApiClient.java @@ -0,0 +1,143 @@ +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.RelatedResponseDto; +import com.mey.backend.domain.place.entity.Place; +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.Map; + +@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.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; + + public List fetchRelatedPlaces(double latitude, double longitude, String language) { + + 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(uriBase) + .path("/locationBasedList2") + .queryParam("serviceKey", encodedKey) + .queryParam("MobileOS", mobileOs) + .queryParam("MobileApp", mobileApp) + .queryParam("_type", "json") + .queryParam("mapX", longitude) // 경도 + .queryParam("mapY", latitude) // 위도 + .queryParam("radius", 500) // 500m 반경 + .queryParam("arrange", "E") // 거리순 정렬 + .queryParam("numOfRows", 10) + .queryParam("pageNo", 1) + .build(true).toUri(); + + try { + String body = RestClient.builder().baseUrl("") + .build() + .get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String.class); + + List out = new ArrayList<>(); + JsonNode items = om.readTree(body).at("/response/body/items/item"); + + if (items.isArray()) { + log.info("📍 locationBasedList2 {}건 lat={}, lon={}", items.size(), latitude, longitude); + + for (JsonNode it : items) { + String address = it.path("addr1").asText(""); + int typeId = it.path("contenttypeid").asInt(0); + String typeName = getName(typeId, langCode); + + out.add(new RelatedResponseDto( + 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("⚠️ locationBasedList2 결과 없음 lat={}, lon={}", latitude, longitude); + } + + return out; + } catch (Exception e) { + log.error("❌ locationBasedList2 호출 실패 lat={}, lon={}", latitude, longitude, e); + } + return List.of(); + } + + 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"); + } +} 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 e9a5c89..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 @@ -1,24 +1,31 @@ -package com.mey.backend.domain.region.entity; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "regions") -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class Region { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long regionId; - - @Column(nullable = false) - private String nameKo; - - @Column(nullable = false) - private String nameEn; +package com.mey.backend.domain.region.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "regions") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Region { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long regionId; + + @Column(nullable = false) + private String nameKo; + + @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 fcc511c..da1bd38 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 @@ -1,7 +1,7 @@ -package com.mey.backend.domain.region.repository; - -import com.mey.backend.domain.region.entity.Region; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface RegionRepository extends JpaRepository { -} \ No newline at end of file +package com.mey.backend.domain.region.repository; + +import com.mey.backend.domain.region.entity.Region; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionRepository extends JpaRepository { +} 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 6f5f9b1..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 @@ -1,466 +1,466 @@ -package com.mey.backend.domain.route.service; - -import com.mey.backend.domain.place.entity.Place; -import com.mey.backend.domain.place.repository.PlaceRepository; -import com.mey.backend.domain.route.dto.*; -import com.mey.backend.domain.route.entity.*; -import com.mey.backend.domain.route.repository.RoutePlaceRepository; -import com.mey.backend.domain.route.repository.RouteRepository; -import com.mey.backend.domain.region.entity.Region; -import com.mey.backend.domain.region.repository.RegionRepository; -import com.mey.backend.global.exception.PlaceException; -import com.mey.backend.global.exception.RouteException; -import com.mey.backend.global.payload.status.ErrorStatus; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class RouteService { - - private static final LocalTime DEFAULT_START_TIME = LocalTime.of(10, 0); - - private final RouteRepository routeRepository; - private final RoutePlaceRepository routePlaceRepository; - private final RegionRepository regionRepository; - private final TransitClient transitClient; // 실제 구현: TmapTransitClient 등 - private final PlaceRepository placeRepository; - private final SequencePlanner sequencePlanner; // GptSequencePlanner 구현체 주입 - - @Transactional(readOnly = true) - public StartRouteResponse startRoute(Long routeId, double latitude, double longitude) { - Route route = routeRepository.findById(routeId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 루트입니다.")); - - List routePlaces = routePlaceRepository.findByRouteIdOrderByVisitOrder(routeId); - - List places = routePlaces.stream() - .map(RoutePlace::getPlace) // 중간 엔티티에서 Place 꺼내기 - .toList(); - if (places.isEmpty()) { - throw new IllegalStateException("루트에 장소가 없습니다."); - } - - List segments = new ArrayList<>(); - - // 현재 위치 → 첫 번째 장소 - Place firstPlace = places.get(0); - segments.add( - transitClient.route( - "현재 위치", - latitude, longitude, - firstPlace.getNameKo(), - firstPlace.getLatitude(), firstPlace.getLongitude(), - null - ) - ); - - // i → i+1 - for (int i = 0; i < places.size() - 1; i++) { - Place from = places.get(i); - Place to = places.get(i + 1); - - segments.add( - transitClient.route( - from.getNameKo(), - from.getLatitude(), from.getLongitude(), - to.getNameKo(), - to.getLatitude(), to.getLongitude(), - null - ) - ); - } - - return StartRouteResponse.builder() - .segments(segments) - .build(); - } - - @Transactional - public RouteCreateResponseDto createRouteByAI(CreateRouteByPlaceIdsRequestDto req) { - // 1) 검증 - if (req.getPlaceIds() == null || req.getPlaceIds().size() < 2) { - throw new IllegalArgumentException("2개 이상의 placeId가 필요합니다."); - } - - // 2) placeId → Place 조회 (요청 순서 보존) - // findAllById는 순서를 보장하지 않으니, map으로 받아서 placeIds 순회 - Map found = placeRepository.findAllById(req.getPlaceIds()) - .stream().collect(Collectors.toMap(Place::getPlaceId, p -> p)); - List selected = new ArrayList<>(req.getPlaceIds().size()); - for (Long id : req.getPlaceIds()) { - Place p = found.get(id); - if (p == null) throw new PlaceException(ErrorStatus.PLACE_NOT_FOUND); - selected.add(p); - } - - // 3) GPT용 좌표 리스트 구성 (입력 인덱스 = originalIndex) - List coordsForPlan = new ArrayList<>(); - for (int i = 0; i < selected.size(); i++) { - Place p = selected.get(i); - coordsForPlan.add(CoordinateDto.builder() - .lat(p.getLatitude()) - .lng(p.getLongitude()) - .originalIndex(i) - .placeId(p.getPlaceId()) - .build()); - } - - // 4) 방문 순서 최적화 (GPT) - SequencePlanner.PlanResult plan; - try { - plan = sequencePlanner.plan(coordsForPlan); - } catch (Exception e) { - List fallback = IntStream.range(0, coordsForPlan.size()).boxed().toList(); - plan = new SequencePlanner.PlanResult(fallback, 0); - } - List orderIdx = plan.order(); // 프론트 핵심: 입력 인덱스 기준 순서 - - // placeId 기준 순서 리스트도 생성 (옵션) - List orderedPlaceIds = orderIdx.stream() - .map(i -> selected.get(i).getPlaceId()) - .toList(); - - // 5) TMAP으로 총 거리/시간/요금 합계 - Totals totals = computeTotals(selected, orderedPlaceIds); - int totalSec = totals.totalDurationSec(); - int totalMin = Math.max(1, totalSec / 60); - int totalMeter = totals.totalDistanceMeters(); - int totalFare = totals.totalFare(); - - // 6) Route 저장 (새 Place 저장 없음) - Region routeRegion = chooseRegionByMajority(selected); - String titleKo = "AI 추천 루트"; - String titleEn = "AI Recommended Route"; - String descKo = String.format("총 %d개 장소, 약 %d분, %,dm 이동, 예상 교통비 %,d원", - selected.size(), totalMin, totalMeter, totalFare); - String descEn = String.format("Total %d places, ~%d min, %,d m travel, est. fare %,d KRW", - selected.size(), totalMin, totalMeter, totalFare); - String imageUrl = resolveRouteImage(selected); - - Route route = Route.builder() - .region(routeRegion) - .titleKo(titleKo) - .titleEn(titleEn) - .descriptionKo(descKo) - .descriptionEn(descEn) - .imageUrl(imageUrl) - .totalDurationMinutes(totalMin) // 분 - .totalDistance(totalMeter) // m 저장 - .totalCost(totalFare) - .themes(Collections.emptyList()) - .routeType(RouteType.AI) - .build(); - routeRepository.save(route); - - // 7) RoutePlace 저장 (기존 Place만 연결) - int visitOrder = 1; - for (Integer idx : orderIdx) { - Place p = selected.get(idx); - routePlaceRepository.save(RoutePlace.builder() - .route(route) - .place(p) - .visitOrder(visitOrder++) - .recommendDurationMinutes(60) - .build()); - } - - // 8) 응답 - return RouteCreateResponseDto.builder() - .routeId(route.getId()) - .titleKo(route.getTitleKo()) - .titleEn(route.getTitleEn()) - .descriptionKo(route.getDescriptionKo()) - .descriptionEn(route.getDescriptionEn()) - .imageUrl(route.getImageUrl()) - .totalDurationMinutes(route.getTotalDurationMinutes()) - .totalDistance(route.getTotalDistance() / 1000.0) // km로 내려줌 - .totalCost(route.getTotalCost()) - .themes(route.getThemes()) - .routeType(route.getRouteType()) - .regionName(route.getRegion() != null ? route.getRegion().getNameKo() : null) - .order(orderIdx) // [2,0,1] 같은 입력 인덱스 순서 - .orderedPlaceIds(orderedPlaceIds) // [303,101,202] 같은 실제 placeId 순서 - .build(); - } - - private Totals computeTotals(List selected, List orderedPlaceIds) { - Map byId = selected.stream() - .collect(Collectors.toMap(Place::getPlaceId, p -> p)); - - int sec = 0, dist = 0, fare = 0; - - for (int i = 0; i < orderedPlaceIds.size() - 1; i++) { - Place from = byId.get(orderedPlaceIds.get(i)); - Place to = byId.get(orderedPlaceIds.get(i + 1)); - - TransitMetricsDto m = transitClient.metrics( - from.getLatitude(), from.getLongitude(), - to.getLatitude(), to.getLongitude(), - null - ); - sec += m.getDurationSeconds(); - dist += m.getDistanceMeters(); - fare += m.getFare(); - } - return new Totals(sec, dist, fare); - } - - private record Totals(int totalDurationSec, int totalDistanceMeters, int totalFare) { - } - - public RouteRecommendListResponseDto getRecommendedRoutes(List themes, int limit, int offset) { - List allRoutes; - - if (themes != null && !themes.isEmpty()) { - // themes를 JSON 배열 문자열로 변환 - String themesJson = themes.stream() - .map(t -> "\"" + t.name() + "\"") // "FOOD", "CAFE" 이런 식으로 - .collect(Collectors.joining(",", "[", "]")); - - allRoutes = routeRepository.findPopularByThemes(themesJson); - } else { - allRoutes = routeRepository.findByRouteType(RouteType.POPULAR); - } - - // 전체 개수 - int totalCount = allRoutes.size(); - - // 수동 페이지네이션 - List paginatedRoutes = allRoutes.stream() - .skip(offset) - .limit(limit) - .toList(); - - // DTO 변환 - List routes = paginatedRoutes.stream() - .map(this::convertToRouteRecommendDto) - .toList(); - - return RouteRecommendListResponseDto.builder() - .routes(routes) - .totalCount(totalCount) - .build(); - } - - private RouteRecommendResponseDto convertToRouteRecommendDto(Route route) { - return RouteRecommendResponseDto.builder() - .id(route.getId()) - .imageUrl(route.getImageUrl()) - .titleKo(route.getTitleKo()) - .titleEn(route.getTitleEn()) - .regionNameKo(route.getRegion() != null ? route.getRegion().getNameKo() : null) - .regionNameEn(route.getRegion() != null ? route.getRegion().getNameEn() : null) - .descriptionKo(route.getDescriptionKo()) - .descriptionEn(route.getDescriptionEn()) - .themes(route.getThemes()) - .build(); - - } - - private List getAvailableTimes() { - return Arrays.asList( - LocalTime.of(9, 0), - LocalTime.of(10, 0), - LocalTime.of(14, 0) - ); - } - - public RouteDetailResponseDto getRouteDetail(Long routeId, LocalDate date, LocalTime startTime) { - Route route = findRouteById(routeId); - List routePlaces = routePlaceRepository.findByRouteIdOrderByVisitOrder(routeId); - - List placeDtos = routePlaces.isEmpty() - ? createMockRoutePlaces(startTime) - : convertToRoutePlaceDtos(routePlaces, startTime); - - return buildRouteDetailResponse(route, placeDtos); - } - - private Route findRouteById(Long routeId) { - return routeRepository.findById(routeId) - .orElseThrow(() -> new RouteException(ErrorStatus.ROUTE_NOT_FOUND)); - } - - private RouteDetailResponseDto buildRouteDetailResponse(Route route, List placeDtos) { - return RouteDetailResponseDto.builder() - .routeId(route.getId()) - .title(route.getTitleKo()) - .description(route.getDescriptionKo()) - .theme(route.getThemes().isEmpty() ? "" : route.getThemes().get(0).name()) - .totalDistanceKm(BigDecimal.valueOf(route.getTotalDistance())) - .totalDurationMinutes(route.getTotalDurationMinutes()) - .estimatedCost((int) route.getTotalCost()) - .suggestedStartTimes(getAvailableTimes()) - .routePlaces(placeDtos) - .build(); - } - - private List convertToRoutePlaceDtos(List routePlaces, - LocalTime startTime) { - LocalTime currentTime = Optional.ofNullable(startTime).orElse(DEFAULT_START_TIME); - - return routePlaces.stream() - .map(routePlace -> convertToRoutePlaceDto(routePlace, currentTime)) - .toList(); - } - - private RouteDetailResponseDto.RoutePlaceDto convertToRoutePlaceDto(RoutePlace routePlace, LocalTime currentTime) { - LocalTime arrivalTime = Optional.ofNullable(routePlace.getArrivalTime()).orElse(currentTime); - LocalTime departureTime = Optional.ofNullable(routePlace.getDepartureTime()) - .orElse(arrivalTime.plusMinutes(routePlace.getRecommendDurationMinutes())); - - RouteDetailResponseDto.PlaceDto placeDto = buildPlaceDto(routePlace); - - return RouteDetailResponseDto.RoutePlaceDto.builder() - .sequenceOrder(routePlace.getVisitOrder()) - .place(placeDto) - .recommendedDurationMinutes(routePlace.getRecommendDurationMinutes()) - .estimatedArrivalTime(arrivalTime) - .estimatedDepartureTime(departureTime) - .notes(routePlace.getNotes()) - .build(); - } - - private RouteDetailResponseDto.PlaceDto buildPlaceDto(RoutePlace routePlace) { - return RouteDetailResponseDto.PlaceDto.builder() - .placeId(routePlace.getPlace().getPlaceId()) - .name(routePlace.getPlace().getNameKo()) - .description(routePlace.getPlace().getDescriptionKo()) - .latitude(BigDecimal.valueOf(routePlace.getPlace().getLatitude())) - .longitude(BigDecimal.valueOf(routePlace.getPlace().getLongitude())) - .address(routePlace.getPlace().getAddress()) - .imageUrls(Arrays.asList(routePlace.getPlace().getImageUrl())) - .openingHours(routePlace.getPlace().getOpeningHours()) - .build(); - } - - private List createMockRoutePlaces(LocalTime startTime) { - LocalTime baseStartTime = Optional.ofNullable(startTime).orElse(DEFAULT_START_TIME); - - return Arrays.asList( - RouteDetailResponseDto.RoutePlaceDto.builder() - .sequenceOrder(1) - .place(RouteDetailResponseDto.PlaceDto.builder() - .placeId(1L) - .name("명동 쇼핑센터") - .description("명동의 대표적인 쇼핑센터") - .latitude(new BigDecimal("37.563600")) - .longitude(new BigDecimal("126.982400")) - .address("서울특별시 중구 명동길 14") - .imageUrls(List.of("https://example.com/place1.jpg")) - .openingHours(Map.of( - "monday", "10:00-22:00", - "tuesday", "10:00-22:00", - "wednesday", "10:00-22:00", - "thursday", "10:00-22:00", - "friday", "10:00-22:00", - "saturday", "10:00-22:00", - "sunday", "10:00-21:00" - )) - .build()) - .recommendedDurationMinutes(90) - .estimatedArrivalTime(baseStartTime) - .estimatedDepartureTime(baseStartTime.plusMinutes(90)) - .notes("쇼핑과 브런치를 즐길 수 있는 곳") - .build(), - RouteDetailResponseDto.RoutePlaceDto.builder() - .sequenceOrder(2) - .place(RouteDetailResponseDto.PlaceDto.builder() - .placeId(2L) - .name("명동성당") - .description("역사적인 명동성당") - .latitude(new BigDecimal("37.563500")) - .longitude(new BigDecimal("126.986200")) - .address("서울특별시 중구 명동길 74") - .imageUrls(List.of("https://example.com/place2.jpg")) - .openingHours(Map.of( - "monday", "06:00-21:00", - "tuesday", "06:00-21:00", - "wednesday", "06:00-21:00", - "thursday", "06:00-21:00", - "friday", "06:00-21:00", - "saturday", "06:00-21:00", - "sunday", "06:00-21:00" - )) - .build()) - .recommendedDurationMinutes(60) - .estimatedArrivalTime(baseStartTime.plusMinutes(105)) - .estimatedDepartureTime(baseStartTime.plusMinutes(165)) - .notes("역사와 문화를 체험할 수 있는 장소") - .build() - ); - } - - - private Region chooseRegionByMajority(List places) { - - return places.stream() - .map(Place::getRegion) - .filter(Objects::nonNull) - .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())) - .entrySet().stream() - .max(Map.Entry.comparingByValue()) - .map(Map.Entry::getKey) - .orElse(null); // 전부 null이면 null 허용 (DB 제약 확인) - } - - private String resolveRouteImage(List places) { - // 1) 첫 장소의 이미지, 2) 없으면 기본 이미지 - for (Place p : places) { - if (p.getImageUrl() != null && !p.getImageUrl().isBlank()) { - return p.getImageUrl(); - } - } - return "/static/images/route-default.png"; // 프로젝트 맞게 경로/URL 지정 - } - - public RouteCreateResponseDto createRoute(RouteCreateRequestDto request) { - Region region = null; - if (request.getRegionId() != null) { - region = regionRepository.findById(request.getRegionId()) - .orElseThrow(() -> new RouteException(ErrorStatus.ROUTE_NOT_FOUND)); - } - - Route route = Route.builder() - .region(region) - .titleKo(request.getTitleKo()) - .titleEn(request.getTitleEn()) - .descriptionKo(request.getDescriptionKo()) - .descriptionEn(request.getDescriptionEn()) - .imageUrl(request.getImageUrl()) - .totalDurationMinutes(request.getTotalDurationMinutes()) - .totalDistance(request.getTotalDistance()) - .totalCost(request.getTotalCost()) - .themes(request.getThemes()) - .routeType(request.getRouteType()) - .build(); - - Route savedRoute = routeRepository.save(route); - - return RouteCreateResponseDto.builder() - .routeId(savedRoute.getId()) - .titleKo(savedRoute.getTitleKo()) - .titleEn(savedRoute.getTitleEn()) - .descriptionKo(savedRoute.getDescriptionKo()) - .descriptionEn(savedRoute.getDescriptionEn()) - .imageUrl(savedRoute.getImageUrl()) - .totalDurationMinutes(savedRoute.getTotalDurationMinutes()) - .totalDistance(savedRoute.getTotalDistance()) - .totalCost(savedRoute.getTotalCost()) - .themes(savedRoute.getThemes()) - .routeType(savedRoute.getRouteType()) - .regionName(savedRoute.getRegion() != null ? savedRoute.getRegion().getNameKo() : null) - .build(); - } -} +package com.mey.backend.domain.route.service; + +import com.mey.backend.domain.place.entity.Place; +import com.mey.backend.domain.place.repository.PlaceRepository; +import com.mey.backend.domain.route.dto.*; +import com.mey.backend.domain.route.entity.*; +import com.mey.backend.domain.route.repository.RoutePlaceRepository; +import com.mey.backend.domain.route.repository.RouteRepository; +import com.mey.backend.domain.region.entity.Region; +import com.mey.backend.domain.region.repository.RegionRepository; +import com.mey.backend.global.exception.PlaceException; +import com.mey.backend.global.exception.RouteException; +import com.mey.backend.global.payload.status.ErrorStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RouteService { + + private static final LocalTime DEFAULT_START_TIME = LocalTime.of(10, 0); + + private final RouteRepository routeRepository; + private final RoutePlaceRepository routePlaceRepository; + private final RegionRepository regionRepository; + private final TransitClient transitClient; // 실제 구현: TmapTransitClient 등 + private final PlaceRepository placeRepository; + private final SequencePlanner sequencePlanner; // GptSequencePlanner 구현체 주입 + + @Transactional(readOnly = true) + public StartRouteResponse startRoute(Long routeId, double latitude, double longitude) { + Route route = routeRepository.findById(routeId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 루트입니다.")); + + List routePlaces = routePlaceRepository.findByRouteIdOrderByVisitOrder(routeId); + + List places = routePlaces.stream() + .map(RoutePlace::getPlace) // 중간 엔티티에서 Place 꺼내기 + .toList(); + if (places.isEmpty()) { + throw new IllegalStateException("루트에 장소가 없습니다."); + } + + List segments = new ArrayList<>(); + + // 현재 위치 → 첫 번째 장소 + Place firstPlace = places.get(0); + segments.add( + transitClient.route( + "현재 위치", + latitude, longitude, + firstPlace.getNameKo(), + firstPlace.getLatitude(), firstPlace.getLongitude(), + null + ) + ); + + // i → i+1 + for (int i = 0; i < places.size() - 1; i++) { + Place from = places.get(i); + Place to = places.get(i + 1); + + segments.add( + transitClient.route( + from.getNameKo(), + from.getLatitude(), from.getLongitude(), + to.getNameKo(), + to.getLatitude(), to.getLongitude(), + null + ) + ); + } + + return StartRouteResponse.builder() + .segments(segments) + .build(); + } + + @Transactional + public RouteCreateResponseDto createRouteByAI(CreateRouteByPlaceIdsRequestDto req) { + // 1) 검증 + if (req.getPlaceIds() == null || req.getPlaceIds().size() < 2) { + throw new IllegalArgumentException("2개 이상의 placeId가 필요합니다."); + } + + // 2) placeId → Place 조회 (요청 순서 보존) + // findAllById는 순서를 보장하지 않으니, map으로 받아서 placeIds 순회 + Map found = placeRepository.findAllById(req.getPlaceIds()) + .stream().collect(Collectors.toMap(Place::getPlaceId, p -> p)); + List selected = new ArrayList<>(req.getPlaceIds().size()); + for (Long id : req.getPlaceIds()) { + Place p = found.get(id); + if (p == null) throw new PlaceException(ErrorStatus.PLACE_NOT_FOUND); + selected.add(p); + } + + // 3) GPT용 좌표 리스트 구성 (입력 인덱스 = originalIndex) + List coordsForPlan = new ArrayList<>(); + for (int i = 0; i < selected.size(); i++) { + Place p = selected.get(i); + coordsForPlan.add(CoordinateDto.builder() + .lat(p.getLatitude()) + .lng(p.getLongitude()) + .originalIndex(i) + .placeId(p.getPlaceId()) + .build()); + } + + // 4) 방문 순서 최적화 (GPT) + SequencePlanner.PlanResult plan; + try { + plan = sequencePlanner.plan(coordsForPlan); + } catch (Exception e) { + List fallback = IntStream.range(0, coordsForPlan.size()).boxed().toList(); + plan = new SequencePlanner.PlanResult(fallback, 0); + } + List orderIdx = plan.order(); // 프론트 핵심: 입력 인덱스 기준 순서 + + // placeId 기준 순서 리스트도 생성 (옵션) + List orderedPlaceIds = orderIdx.stream() + .map(i -> selected.get(i).getPlaceId()) + .toList(); + + // 5) TMAP으로 총 거리/시간/요금 합계 + Totals totals = computeTotals(selected, orderedPlaceIds); + int totalSec = totals.totalDurationSec(); + int totalMin = Math.max(1, totalSec / 60); + int totalMeter = totals.totalDistanceMeters(); + int totalFare = totals.totalFare(); + + // 6) Route 저장 (새 Place 저장 없음) + Region routeRegion = chooseRegionByMajority(selected); + String titleKo = "AI 추천 루트"; + String titleEn = "AI Recommended Route"; + String descKo = String.format("총 %d개 장소, 약 %d분, %,dm 이동, 예상 교통비 %,d원", + selected.size(), totalMin, totalMeter, totalFare); + String descEn = String.format("Total %d places, ~%d min, %,d m travel, est. fare %,d KRW", + selected.size(), totalMin, totalMeter, totalFare); + String imageUrl = resolveRouteImage(selected); + + Route route = Route.builder() + .region(routeRegion) + .titleKo(titleKo) + .titleEn(titleEn) + .descriptionKo(descKo) + .descriptionEn(descEn) + .imageUrl(imageUrl) + .totalDurationMinutes(totalMin) // 분 + .totalDistance(totalMeter) // m 저장 + .totalCost(totalFare) + .themes(Collections.emptyList()) + .routeType(RouteType.AI) + .build(); + routeRepository.save(route); + + // 7) RoutePlace 저장 (기존 Place만 연결) + int visitOrder = 1; + for (Integer idx : orderIdx) { + Place p = selected.get(idx); + routePlaceRepository.save(RoutePlace.builder() + .route(route) + .place(p) + .visitOrder(visitOrder++) + .recommendDurationMinutes(60) + .build()); + } + + // 8) 응답 + return RouteCreateResponseDto.builder() + .routeId(route.getId()) + .titleKo(route.getTitleKo()) + .titleEn(route.getTitleEn()) + .descriptionKo(route.getDescriptionKo()) + .descriptionEn(route.getDescriptionEn()) + .imageUrl(route.getImageUrl()) + .totalDurationMinutes(route.getTotalDurationMinutes()) + .totalDistance(route.getTotalDistance() / 1000.0) // km로 내려줌 + .totalCost(route.getTotalCost()) + .themes(route.getThemes()) + .routeType(route.getRouteType()) + .regionName(route.getRegion() != null ? route.getRegion().getNameKo() : null) + .order(orderIdx) // [2,0,1] 같은 입력 인덱스 순서 + .orderedPlaceIds(orderedPlaceIds) // [303,101,202] 같은 실제 placeId 순서 + .build(); + } + + private Totals computeTotals(List selected, List orderedPlaceIds) { + Map byId = selected.stream() + .collect(Collectors.toMap(Place::getPlaceId, p -> p)); + + int sec = 0, dist = 0, fare = 0; + + for (int i = 0; i < orderedPlaceIds.size() - 1; i++) { + Place from = byId.get(orderedPlaceIds.get(i)); + Place to = byId.get(orderedPlaceIds.get(i + 1)); + + TransitMetricsDto m = transitClient.metrics( + from.getLatitude(), from.getLongitude(), + to.getLatitude(), to.getLongitude(), + null + ); + sec += m.getDurationSeconds(); + dist += m.getDistanceMeters(); + fare += m.getFare(); + } + return new Totals(sec, dist, fare); + } + + private record Totals(int totalDurationSec, int totalDistanceMeters, int totalFare) { + } + + public RouteRecommendListResponseDto getRecommendedRoutes(List themes, int limit, int offset) { + List allRoutes; + + if (themes != null && !themes.isEmpty()) { + // themes를 JSON 배열 문자열로 변환 + String themesJson = themes.stream() + .map(t -> "\"" + t.name() + "\"") // "FOOD", "CAFE" 이런 식으로 + .collect(Collectors.joining(",", "[", "]")); + + allRoutes = routeRepository.findPopularByThemes(themesJson); + } else { + allRoutes = routeRepository.findByRouteType(RouteType.POPULAR); + } + + // 전체 개수 + int totalCount = allRoutes.size(); + + // 수동 페이지네이션 + List paginatedRoutes = allRoutes.stream() + .skip(offset) + .limit(limit) + .toList(); + + // DTO 변환 + List routes = paginatedRoutes.stream() + .map(this::convertToRouteRecommendDto) + .toList(); + + return RouteRecommendListResponseDto.builder() + .routes(routes) + .totalCount(totalCount) + .build(); + } + + private RouteRecommendResponseDto convertToRouteRecommendDto(Route route) { + return RouteRecommendResponseDto.builder() + .id(route.getId()) + .imageUrl(route.getImageUrl()) + .titleKo(route.getTitleKo()) + .titleEn(route.getTitleEn()) + .regionNameKo(route.getRegion() != null ? route.getRegion().getNameKo() : null) + .regionNameEn(route.getRegion() != null ? route.getRegion().getNameEn() : null) + .descriptionKo(route.getDescriptionKo()) + .descriptionEn(route.getDescriptionEn()) + .themes(route.getThemes()) + .build(); + + } + + private List getAvailableTimes() { + return Arrays.asList( + LocalTime.of(9, 0), + LocalTime.of(10, 0), + LocalTime.of(14, 0) + ); + } + + public RouteDetailResponseDto getRouteDetail(Long routeId, LocalDate date, LocalTime startTime) { + Route route = findRouteById(routeId); + List routePlaces = routePlaceRepository.findByRouteIdOrderByVisitOrder(routeId); + + List placeDtos = routePlaces.isEmpty() + ? createMockRoutePlaces(startTime) + : convertToRoutePlaceDtos(routePlaces, startTime); + + return buildRouteDetailResponse(route, placeDtos); + } + + private Route findRouteById(Long routeId) { + return routeRepository.findById(routeId) + .orElseThrow(() -> new RouteException(ErrorStatus.ROUTE_NOT_FOUND)); + } + + private RouteDetailResponseDto buildRouteDetailResponse(Route route, List placeDtos) { + return RouteDetailResponseDto.builder() + .routeId(route.getId()) + .title(route.getTitleKo()) + .description(route.getDescriptionKo()) + .theme(route.getThemes().isEmpty() ? "" : route.getThemes().get(0).name()) + .totalDistanceKm(BigDecimal.valueOf(route.getTotalDistance())) + .totalDurationMinutes(route.getTotalDurationMinutes()) + .estimatedCost((int) route.getTotalCost()) + .suggestedStartTimes(getAvailableTimes()) + .routePlaces(placeDtos) + .build(); + } + + private List convertToRoutePlaceDtos(List routePlaces, + LocalTime startTime) { + LocalTime currentTime = Optional.ofNullable(startTime).orElse(DEFAULT_START_TIME); + + return routePlaces.stream() + .map(routePlace -> convertToRoutePlaceDto(routePlace, currentTime)) + .toList(); + } + + private RouteDetailResponseDto.RoutePlaceDto convertToRoutePlaceDto(RoutePlace routePlace, LocalTime currentTime) { + LocalTime arrivalTime = Optional.ofNullable(routePlace.getArrivalTime()).orElse(currentTime); + LocalTime departureTime = Optional.ofNullable(routePlace.getDepartureTime()) + .orElse(arrivalTime.plusMinutes(routePlace.getRecommendDurationMinutes())); + + RouteDetailResponseDto.PlaceDto placeDto = buildPlaceDto(routePlace); + + return RouteDetailResponseDto.RoutePlaceDto.builder() + .sequenceOrder(routePlace.getVisitOrder()) + .place(placeDto) + .recommendedDurationMinutes(routePlace.getRecommendDurationMinutes()) + .estimatedArrivalTime(arrivalTime) + .estimatedDepartureTime(departureTime) + .notes(routePlace.getNotes()) + .build(); + } + + private RouteDetailResponseDto.PlaceDto buildPlaceDto(RoutePlace routePlace) { + return RouteDetailResponseDto.PlaceDto.builder() + .placeId(routePlace.getPlace().getPlaceId()) + .name(routePlace.getPlace().getNameKo()) + .description(routePlace.getPlace().getDescriptionKo()) + .latitude(BigDecimal.valueOf(routePlace.getPlace().getLatitude())) + .longitude(BigDecimal.valueOf(routePlace.getPlace().getLongitude())) + .address(routePlace.getPlace().getAddressKo()) + .imageUrls(Arrays.asList(routePlace.getPlace().getImageUrl())) + .openingHours(routePlace.getPlace().getOpeningHours()) + .build(); + } + + private List createMockRoutePlaces(LocalTime startTime) { + LocalTime baseStartTime = Optional.ofNullable(startTime).orElse(DEFAULT_START_TIME); + + return Arrays.asList( + RouteDetailResponseDto.RoutePlaceDto.builder() + .sequenceOrder(1) + .place(RouteDetailResponseDto.PlaceDto.builder() + .placeId(1L) + .name("명동 쇼핑센터") + .description("명동의 대표적인 쇼핑센터") + .latitude(new BigDecimal("37.563600")) + .longitude(new BigDecimal("126.982400")) + .address("서울특별시 중구 명동길 14") + .imageUrls(List.of("https://example.com/place1.jpg")) + .openingHours(Map.of( + "monday", "10:00-22:00", + "tuesday", "10:00-22:00", + "wednesday", "10:00-22:00", + "thursday", "10:00-22:00", + "friday", "10:00-22:00", + "saturday", "10:00-22:00", + "sunday", "10:00-21:00" + )) + .build()) + .recommendedDurationMinutes(90) + .estimatedArrivalTime(baseStartTime) + .estimatedDepartureTime(baseStartTime.plusMinutes(90)) + .notes("쇼핑과 브런치를 즐길 수 있는 곳") + .build(), + RouteDetailResponseDto.RoutePlaceDto.builder() + .sequenceOrder(2) + .place(RouteDetailResponseDto.PlaceDto.builder() + .placeId(2L) + .name("명동성당") + .description("역사적인 명동성당") + .latitude(new BigDecimal("37.563500")) + .longitude(new BigDecimal("126.986200")) + .address("서울특별시 중구 명동길 74") + .imageUrls(List.of("https://example.com/place2.jpg")) + .openingHours(Map.of( + "monday", "06:00-21:00", + "tuesday", "06:00-21:00", + "wednesday", "06:00-21:00", + "thursday", "06:00-21:00", + "friday", "06:00-21:00", + "saturday", "06:00-21:00", + "sunday", "06:00-21:00" + )) + .build()) + .recommendedDurationMinutes(60) + .estimatedArrivalTime(baseStartTime.plusMinutes(105)) + .estimatedDepartureTime(baseStartTime.plusMinutes(165)) + .notes("역사와 문화를 체험할 수 있는 장소") + .build() + ); + } + + + private Region chooseRegionByMajority(List places) { + + return places.stream() + .map(Place::getRegion) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())) + .entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(null); // 전부 null이면 null 허용 (DB 제약 확인) + } + + private String resolveRouteImage(List places) { + // 1) 첫 장소의 이미지, 2) 없으면 기본 이미지 + for (Place p : places) { + if (p.getImageUrl() != null && !p.getImageUrl().isBlank()) { + return p.getImageUrl(); + } + } + return "/static/images/route-default.png"; // 프로젝트 맞게 경로/URL 지정 + } + + public RouteCreateResponseDto createRoute(RouteCreateRequestDto request) { + Region region = null; + if (request.getRegionId() != null) { + region = regionRepository.findById(request.getRegionId()) + .orElseThrow(() -> new RouteException(ErrorStatus.ROUTE_NOT_FOUND)); + } + + Route route = Route.builder() + .region(region) + .titleKo(request.getTitleKo()) + .titleEn(request.getTitleEn()) + .descriptionKo(request.getDescriptionKo()) + .descriptionEn(request.getDescriptionEn()) + .imageUrl(request.getImageUrl()) + .totalDurationMinutes(request.getTotalDurationMinutes()) + .totalDistance(request.getTotalDistance()) + .totalCost(request.getTotalCost()) + .themes(request.getThemes()) + .routeType(request.getRouteType()) + .build(); + + Route savedRoute = routeRepository.save(route); + + return RouteCreateResponseDto.builder() + .routeId(savedRoute.getId()) + .titleKo(savedRoute.getTitleKo()) + .titleEn(savedRoute.getTitleEn()) + .descriptionKo(savedRoute.getDescriptionKo()) + .descriptionEn(savedRoute.getDescriptionEn()) + .imageUrl(savedRoute.getImageUrl()) + .totalDurationMinutes(savedRoute.getTotalDurationMinutes()) + .totalDistance(savedRoute.getTotalDistance()) + .totalCost(savedRoute.getTotalCost()) + .themes(savedRoute.getThemes()) + .routeType(savedRoute.getRouteType()) + .regionName(savedRoute.getRegion() != null ? savedRoute.getRegion().getNameKo() : null) + .build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 52e15b0..3d93c69 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,52 +1,63 @@ -spring: - application: - name: backend - - config: - import: optional:file:.env[.properties] - - profiles: - active: dev - - ai: - openai: - api-key: ${OPENAI_API_KEY} - embedding: - options: - model: text-embedding-3-small - -springdoc: - swagger-ui: - path: /swagger - -jwt: - secret: ${JWT_SECRET} - access-token-validity: 3600000 # 1시간 (밀리초) - refresh-token-validity: 604800000 # 7일 (밀리초) - -openai: - api: - key: ${OPENAI_API_KEY:dummy} - url: https://api.openai.com/v1/chat/completions - -tmap: - transit: - base-url: https://apis.openapi.sk.com/transit - app-key: ${TMAP_APP_KEY:dummy} - lang: 0 # 0=Korean - count: 1 - -cloud: - aws: - s3: - bucket: mey-cd-bucket - path: - profile: profile - place: place - region: - static: ap-northeast-2 - stack: - auto: false - credentials: - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} +spring: + application: + name: backend + + config: + import: optional:file:.env[.properties] + + profiles: + active: dev + + ai: + openai: + api-key: ${OPENAI_API_KEY} + embedding: + options: + model: text-embedding-3-small + +springdoc: + swagger-ui: + path: /swagger + +jwt: + secret: ${JWT_SECRET} + access-token-validity: 3600000 # 1시간 (밀리초) + refresh-token-validity: 604800000 # 7일 (밀리초) + +openai: + api: + key: ${OPENAI_API_KEY:dummy} + url: https://api.openai.com/v1/chat/completions + +tmap: + transit: + base-url: https://apis.openapi.sk.com/transit + app-key: ${TMAP_APP_KEY:dummy} + 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: + bucket: mey-cd-bucket + path: + profile: profile + place: place + region: + static: ap-northeast-2 + stack: + auto: false + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} \ No newline at end of file diff --git a/src/main/resources/sql/data-mysql.sql b/src/main/resources/sql/data-mysql.sql index 8f2d68a..8b84904 100644 --- a/src/main/resources/sql/data-mysql.sql +++ b/src/main/resources/sql/data-mysql.sql @@ -1,13 +1,12 @@ -- REGION 추가 -insert into regions(region_id, name_ko, name_en) -values - (1, '서울', 'Seoul'), - (2, '부산', 'Busan'); - +INSERT INTO regions(region_id, name_ko, name_en, name_jp, name_ch) +VALUES + (1, '서울', 'Seoul', 'ソウル', '首尔'), + (2, '부산', 'Busan', 'プサン', '釜山'); -- PLACE 추가 -- K-pop 장소 (서울, region_id = 1) -INSERT INTO places (region_id, name_ko, name_en, description_ko, description_en, longitude, latitude, image_url, address, contact_info, website_url, kakao_place_id, tour_api_place_id, opening_hours, themes, cost_info, created_at, updated_at) +INSERT INTO places (region_id, name_ko, name_en, description_ko, description_en, longitude, latitude, image_url, address_ko, contact_info, website_url, kakao_place_id, tour_api_place_id, opening_hours, themes, cost_info, created_at, updated_at) VALUES (1, 'JYP 사옥', 'JYP Entertainment Building', 'JYP 엔터테인먼트의 본사 사옥으로 박진영이 설립한 대한민국 3대 기획사 중 하나입니다. 원더걸스, TWICE, Stray Kids 등 수많은 K-pop 스타들이 소속된 기획사의 본사로, 팬들에게는 성지와 같은 곳입니다.', 'The headquarters building of JYP Entertainment, one of Korea''s top 3 entertainment companies founded by Park Jin-young. Home to numerous K-pop stars including Wonder Girls, TWICE, and Stray Kids, this building serves as a pilgrimage site for fans worldwide.', 127.0276, 37.5413, 'https://example.com/jyp.jpg', '서울특별시 강동구 성내동', '02-3401-0114', 'https://www.jype.com', 'jyp_kakao_001', 'jyp_tour_001', '{"monday": "09:00-18:00", "tuesday": "09:00-18:00", "wednesday": "09:00-18:00", "thursday": "09:00-18:00", "friday": "09:00-18:00", "saturday": "closed", "sunday": "closed"}', '["K_POP"]', '무료 (외부 관람만 가능)', NOW(), NOW()), (1, 'SM ARTIST & MUSIC CENTER', 'SM ARTIST & Music CENTER', 'SM 엔터테인먼트의 아티스트 및 뮤직 센터로 한국 대표 엔터테인먼트 기업의 문화 복합 공간입니다. 소녀시대, EXO, aespa 등 글로벌 K-pop 스타들의 굿즈와 음반을 만나볼 수 있으며, 팬들을 위한 다양한 이벤트와 전시가 열리는 곳입니다.', 'SM Entertainment''s Artist & Music Center, a cultural complex space of Korea''s leading entertainment company. Visitors can explore merchandise and albums of global K-pop stars like Girls'' Generation, EXO, and aespa, with various events and exhibitions held for fans.', 127.0456, 37.5287, 'https://example.com/sm_center.jpg', '서울특별시 강남구 압구정로', '02-3438-0100', 'https://www.smtown.com', 'sm_center_kakao_002', 'sm_center_tour_002', '{"monday": "10:00-19:00", "tuesday": "10:00-19:00", "wednesday": "10:00-19:00", "thursday": "10:00-19:00", "friday": "10:00-19:00", "saturday": "10:00-18:00", "sunday": "10:00-18:00"}', '["K_POP"]', '무료 입장, 굿즈 구매 별도', NOW(), NOW()), @@ -24,7 +23,7 @@ VALUES (1, '더키월드', 'The Key World', '케이팝과 관련된 다양한 체험과 쇼핑을 한 번에 즐길 수 있는 복합 엔터테인먼트 공간입니다. VR 체험부터 포토존, 굿즈 쇼핑까지 K-pop 팬들이 꿈꿔온 모든 것을 경험할 수 있으며, 특히 인터랙티브한 체험 프로그램들로 유명합니다.', 'A complex entertainment space where visitors can enjoy various K-pop related experiences and shopping all at once. From VR experiences to photo zones and goods shopping, fans can experience everything they''ve dreamed of, particularly famous for its interactive experience programs.', 126.9756, 37.5658, 'https://example.com/the_key_world.jpg', '서울특별시 중구 을지로', '02-2222-3333', 'https://www.thekeyworld.com', 'thekey_kakao_013', 'thekey_tour_013', '{"monday": "10:00-21:00", "tuesday": "10:00-21:00", "wednesday": "10:00-21:00", "thursday": "10:00-21:00", "friday": "10:00-22:00", "saturday": "10:00-22:00", "sunday": "10:00-21:00"}', '["K_POP"]', '입장료 10,000원, 체험비 별도', NOW(), NOW()); -- K-drama 장소 (부산, region_id = 2) -INSERT INTO places (region_id, name_ko, name_en, description_ko, description_en, longitude, latitude, image_url, address, contact_info, website_url, kakao_place_id, tour_api_place_id, opening_hours, themes, cost_info, created_at, updated_at) +INSERT INTO places (region_id, name_ko, name_en, description_ko, description_en, longitude, latitude, image_url, address_ko, contact_info, website_url, kakao_place_id, tour_api_place_id, opening_hours, themes, cost_info, created_at, updated_at) VALUES (2, '아홉산 숲', 'Ahopsan Forest', '부산 기장군에 위치한 아름다운 자연 숲으로 다양한 드라마와 영화의 촬영지로 사랑받고 있는 명소입니다. 울창한 숲과 맑은 공기, 그리고 드라마 속 로맨틱한 장면들이 촬영된 곳으로 연인들과 가족 단위 방문객들에게 인기가 높습니다.', 'A beautiful natural forest located in Gijang-gun, Busan, beloved as a filming location for various dramas and movies. Popular among couples and families for its dense forest, fresh air, and romantic scenes from dramas filmed here.', 129.0756, 35.1796, 'https://example.com/ahopsan.jpg', '부산광역시 기장군 철마면', '051-709-4000', NULL, 'ahopsan_kakao_014', 'ahopsan_tour_014', '{"monday": "상시 개방", "tuesday": "상시 개방", "wednesday": "상시 개방", "thursday": "상시 개방", "friday": "상시 개방", "saturday": "상시 개방", "sunday": "상시 개방"}', '["K_DRAMA"]', '무료 입장', NOW(), NOW()), (2, '부산시민공원', 'Busan Citizens Park', '부산의 대표적인 도심 공원으로 시민들의 휴식 공간이자 여러 드라마의 촬영지로 활용되고 있는 곳입니다. 넓은 잔디밭과 아름다운 산책로, 그리고 다양한 문화 시설들이 어우러져 있어 관광객들에게도 인기가 높은 명소입니다.', 'Busan''s representative urban park serving as a resting place for citizens and utilized as a filming location for various dramas. Popular among tourists for its vast lawns, beautiful walking trails, and diverse cultural facilities harmoniously integrated.', 129.0588, 35.1677, 'https://example.com/citizens_park.jpg', '부산광역시 부산진구 시민공원로', '051-850-6000', 'https://www.bscitizenspark.or.kr', 'citizens_park_kakao_015', 'citizens_park_tour_015', '{"monday": "05:00-23:00", "tuesday": "05:00-23:00", "wednesday": "05:00-23:00", "thursday": "05:00-23:00", "friday": "05:00-23:00", "saturday": "05:00-23:00", "sunday": "05:00-23:00"}', '["K_DRAMA"]', '무료 입장', NOW(), NOW()), @@ -41,7 +40,7 @@ VALUES -- K-beauty 장소 Mock Data DML (서울, region_id = 1) -INSERT INTO places (region_id, name_ko, name_en, description_ko, description_en, longitude, latitude, image_url, address, contact_info, website_url, kakao_place_id, tour_api_place_id, opening_hours, themes, cost_info, created_at, updated_at) +INSERT INTO places (region_id, name_ko, name_en, description_ko, description_en, longitude, latitude, image_url, address_ko, contact_info, website_url, kakao_place_id, tour_api_place_id, opening_hours, themes, cost_info, created_at, updated_at) VALUES (1, '라네즈 맞춤 쿠션 명동점', 'Laneige Custom Cushion Myeongdong', '명동에 위치한 라네즈 플래그십 스토어로 개인 맞춤형 뷰티 체험을 제공하는 K-beauty의 대표 공간입니다. 첨단 기술로 개인의 피부톤을 분석하여 맞춤형 쿠션을 제작해주며, 전문 뷰티 컨설턴트의 상담을 받을 수 있습니다.', 'Laneige flagship store located in Myeongdong, providing personalized beauty experiences as a representative space of K-beauty. Using advanced technology to analyze individual skin tones and create customized cushions, with consultation from professional beauty consultants available.', 126.9849, 37.5636, 'https://example.com/laneige_myeongdong.jpg', '서울특별시 중구 명동길', '02-3393-2233', 'https://www.laneige.com', 'laneige_myeongdong_kakao_001', 'laneige_myeongdong_tour_001', '{"monday": "10:30-21:30", "tuesday": "10:30-21:30", "wednesday": "10:30-21:30", "thursday": "10:30-21:30", "friday": "10:30-22:00", "saturday": "10:30-22:00", "sunday": "10:30-21:30"}', '["K_BEAUTY"]', '무료 상담, 맞춤 쿠션 제작비 별도', NOW(), NOW()), (1, '아모레퍼시픽 플래그십 성수점', 'Amorepacific Flagship Seongsu', '성수동에 위치한 아모레퍼시픽 플래그십 스토어로 개인 맞춤형 뷰티 체험과 브랜드 문화를 경험할 수 있는 혁신적인 공간입니다. AI 스킨 진단부터 맞춤형 제품 추천까지 첨단 기술과 K-beauty의 노하우가 결합된 특별한 뷰티 여정을 제공합니다.', 'Amorepacific flagship store located in Seongsu-dong, an innovative space where visitors can experience personalized beauty services and brand culture. Offering a special beauty journey combining cutting-edge technology and K-beauty expertise, from AI skin diagnosis to customized product recommendations.', 127.0553, 37.5448, 'https://example.com/amorepacific_seongsu.jpg', '서울특별시 성동구 왕십리로', '02-709-5114', 'https://www.amorepacific.com', 'amorepacific_seongsu_kakao_002', 'amorepacific_seongsu_tour_002', '{"monday": "11:00-20:00", "tuesday": "11:00-20:00", "wednesday": "11:00-20:00", "thursday": "11:00-20:00", "friday": "11:00-21:00", "saturday": "10:00-21:00", "sunday": "11:00-20:00"}', '["K_BEAUTY"]', '무료 체험, 일부 프리미엄 서비스 유료', NOW(), NOW()),