diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..6186e09 Binary files /dev/null and b/.DS_Store differ diff --git a/build.gradle b/build.gradle index 316fac3..7786006 100644 --- a/build.gradle +++ b/build.gradle @@ -76,6 +76,12 @@ dependencies { implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation "com.fasterxml.jackson.module:jackson-module-kotlin" + // xml + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.0") + + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + } diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/domain/Festival.kt b/src/main/kotlin/busanVibe/busan/domain/festival/domain/Festival.kt index a069861..d16d37d 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/domain/Festival.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/domain/Festival.kt @@ -2,6 +2,7 @@ package busanVibe.busan.domain.festival.domain import busanVibe.busan.domain.common.BaseEntity import busanVibe.busan.domain.festival.enums.FestivalStatus +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType @@ -20,6 +21,9 @@ class Festival ( @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, + @Column(nullable = false, unique = true) + val contentId: Long, + @Column(nullable = false, length = 50) val name: String, @@ -36,7 +40,7 @@ class Festival ( val introduction: String, @Column(nullable = false) - val fee: Int, + val fee: String, @Column(nullable = false) val phone: String, @@ -49,8 +53,8 @@ class Festival ( @Column(nullable = false) val status: FestivalStatus, - @OneToMany(mappedBy = "festival", fetch = FetchType.LAZY) - val festivalImages: Set, + @OneToMany(mappedBy = "festival", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) + val festivalImages: MutableSet = mutableSetOf(), @OneToMany(mappedBy = "festival", fetch = FetchType.LAZY) val festivalLikes: Set @@ -58,6 +62,12 @@ class Festival ( ): BaseEntity(){ + fun addFestivalImage(festivalImage: FestivalImage) { + festivalImages.add(festivalImage) + festivalImage.festival = this + } + + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalImage.kt b/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalImage.kt index 2b640ce..2cabc43 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalImage.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalImage.kt @@ -22,7 +22,7 @@ class FestivalImage( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "festival_id") - val festival: Festival + var festival: Festival ): BaseEntity() { diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalDetailsDTO.kt b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalDetailsDTO.kt index 5f3e191..4d15981 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalDetailsDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalDetailsDTO.kt @@ -18,7 +18,7 @@ class FestivalDetailsDTO { val endDate: String, val address: String, val phone: String, - val fee: Int, + val fee: String, val siteUrl: String, val introduce: String ) diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/enums/FestivalStatus.kt b/src/main/kotlin/busanVibe/busan/domain/festival/enums/FestivalStatus.kt index 5b1cdfb..e38214a 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/enums/FestivalStatus.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/enums/FestivalStatus.kt @@ -5,6 +5,7 @@ enum class FestivalStatus { ALL, IN_PROGRESS, UPCOMING, - COMPLETE + COMPLETE, + UNKNOWN } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt index 759f2a9..0a94cfe 100644 --- a/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt @@ -16,8 +16,8 @@ class HomeResponseDTO { data class MostCongestion( val placeId: Long?, val name: String, - val latitude: Double, - val longitude: Double, + val latitude: Double? = null, + val longitude: Double? = null, val type: String, val image: String?, val congestionLevel: Int, @@ -31,8 +31,8 @@ class HomeResponseDTO { val congestionLevel: Int, val type: String, val image: String?, - val latitude: Double, - val longitude: Double, + val latitude: Double?, + val longitude: Double?, val address: String, @get:JsonProperty("is_liked") val isLiked: Boolean diff --git a/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt index 2a12a6e..28719f9 100644 --- a/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt @@ -45,8 +45,8 @@ class HomeQueryService( HomeResponseDTO.MostCongestion( placeId = place.id, name = place.name, - latitude = place.latitude.toDouble(), - longitude = place.longitude.toDouble(), + latitude = place.latitude?.toDouble(), + longitude = place.longitude?.toDouble(), type = place.type.korean, image = place.placeImages.firstOrNull()?.imgUrl, congestionLevel = congestion, @@ -71,8 +71,8 @@ class HomeQueryService( congestionLevel = congestion, type = place.type.korean, image = place.placeImages.firstOrNull()?.imgUrl, - latitude = place.latitude.toDouble(), - longitude = place.longitude.toDouble(), + latitude = place.latitude?.toDouble(), + longitude = place.longitude?.toDouble(), address = place.address, isLiked = place.placeLikes.any { it.user == currentUser } ) diff --git a/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt b/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt index 18fd428..84ae42e 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt @@ -23,7 +23,10 @@ class Place( @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, - @Column(nullable = false, length = 10) + @Column(nullable = false, unique = true) + val contentId: Long, + + @Column(nullable = false, length = 50) val name: String, @Column(nullable = false) @@ -31,10 +34,10 @@ class Place( val type: PlaceType, @Column(nullable = false) - val latitude: BigDecimal, + val latitude: BigDecimal? = null, @Column(nullable = false) - val longitude: BigDecimal, + val longitude: BigDecimal? = null, @Column(nullable = false, length = 50) val address: String, @@ -42,9 +45,18 @@ class Place( @Column(nullable = false, columnDefinition = "TEXT") val introduction: String, - @Column(nullable = false, length = 20) + @Column(nullable = false, length = 50) val phone: String, + // ----- + + @Column(nullable = false) + val useTime: String, + + @Column(nullable = false) + val restDate: String, + + // @ManyToOne(fetch = FetchType.LAZY) // @JoinColumn(name = "region_id", nullable = false) // val region: Region, @@ -56,13 +68,24 @@ class Place( val placeLikes: Set, @OneToOne(mappedBy = "place", fetch = FetchType.LAZY, optional = true) - val openTime: OpenTime, + val openTime: OpenTime? = null, - @OneToMany(mappedBy="place", fetch = FetchType.LAZY) - val placeImages: Set, + @OneToMany(mappedBy = "place", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) + val placeImages: MutableSet = mutableSetOf(), @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "visitor_distribution_id") val visitorDistribution: VisitorDistribution? = null, -) : BaseEntity() \ No newline at end of file +) : BaseEntity(){ + + fun addImage(imgUrl: String) { + val image = PlaceImage(imgUrl = imgUrl, place = this) + placeImages.add(image) + } + + fun removeImage(image: PlaceImage) { + placeImages.remove(image) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/domain/Region.kt b/src/main/kotlin/busanVibe/busan/domain/place/domain/Region.kt index b6b3b1b..5570cc0 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/domain/Region.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/domain/Region.kt @@ -13,7 +13,7 @@ import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.OneToOne -@Entity +//@Entity class Region( @Id diff --git a/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceMapResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceMapResponseDTO.kt index e620e81..ebe7494 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceMapResponseDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceMapResponseDTO.kt @@ -19,8 +19,8 @@ class PlaceMapResponseDTO { val name: String, val type: String, val congestionLevel: Int, - val latitude: BigDecimal, - val longitude: BigDecimal + val latitude: BigDecimal? = null, + val longitude: BigDecimal? = null ) @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) @@ -30,8 +30,8 @@ class PlaceMapResponseDTO { val congestionLevel: Int, val grade: Float, val reviewAmount: Int, - val latitude: BigDecimal, - val longitude: BigDecimal, + val latitude: BigDecimal?, + val longitude: BigDecimal?, val address: String, @get:JsonProperty("is_open") val isOpen: Boolean, diff --git a/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceType.kt b/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceType.kt index c5b8404..67d7083 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceType.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceType.kt @@ -5,16 +5,24 @@ import lombok.AllArgsConstructor @AllArgsConstructor enum class PlaceType( val korean: String, - val capitalEnglish: String + val capitalEnglish: String, + val tourApiTypeId: String, + val placeUseTimeColumn: String, + val restDateColumn: String, ) { - ALL("전체", "ALL"), - SIGHT("관광지", "SIGHT"), - RESTAURANT("식당", "RESTAURANT"), - CAFE("카페", "CAFE") + ALL("전체", "ALL", "", "", ""), + SIGHT("관광지", "SIGHT", "12", "useTime", "restDate"), + RESTAURANT("식당", "RESTAURANT", "39", "openTimeFood", "restDateFood"), + CAFE("카페", "CAFE", "00", "openTimeFood", "restDateFood"), + CULTURE("문화시설", "CULTURE", "14", "useTimeCulture", "restDateCulture") ; - + companion object{ + fun fromTourApiTypeId(code: String): PlaceType? { + return values().find { it.tourApiTypeId == code } + } + } } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceJdbcRepository.kt b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceJdbcRepository.kt new file mode 100644 index 0000000..1d769ce --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceJdbcRepository.kt @@ -0,0 +1,41 @@ +package busanVibe.busan.domain.place.repository + +import busanVibe.busan.domain.place.domain.Place +import org.springframework.jdbc.core.BatchPreparedStatementSetter +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository + +@Repository +class PlaceJdbcRepository( + private val jdbcTemplate: JdbcTemplate +) { + + fun saveAll(places: List) { + val sql = """ + INSERT INTO place ( + content_id, name, type, latitude, longitude, address, introduction, phone, + use_time, rest_date, created_at, modified_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + jdbcTemplate.batchUpdate(sql, object : BatchPreparedStatementSetter { + override fun getBatchSize(): Int = places.size + + override fun setValues(ps: java.sql.PreparedStatement, i: Int) { + val place = places[i] + ps.setLong(1, place.contentId) + ps.setString(2, place.name) + ps.setString(3, place.type.name) + ps.setBigDecimal(4, place.latitude) + ps.setBigDecimal(5, place.longitude) + ps.setString(6, place.address) + ps.setString(7, place.introduction) + ps.setString(8, place.phone) + ps.setString(9, place.useTime) + ps.setString(10, place.restDate) + ps.setTimestamp(11, java.sql.Timestamp.valueOf(place.createdAt)) + ps.setTimestamp(12, java.sql.Timestamp.valueOf(place.modifiedAt)) + } + }) + } +} diff --git a/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt index 2cb6ffb..45e323f 100644 --- a/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt @@ -66,8 +66,8 @@ class SearchQueryService( typeEn = place.type.capitalEnglish, id = place.id, name = place.name, - latitude = place.latitude.toDouble(), - longitude = place.longitude.toDouble(), + latitude = place.latitude?.toDouble(), + longitude = place.longitude?.toDouble(), address = place.address, congestionLevel = placeRedisUtil.getRedisCongestion(place.id), isLiked = place.placeLikes.any { it.user == currentUser }, diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/controller/TourAPIController.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/controller/TourAPIController.kt new file mode 100644 index 0000000..4fafa1a --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/controller/TourAPIController.kt @@ -0,0 +1,29 @@ +package busanVibe.busan.domain.tourApi.controller + +import busanVibe.busan.domain.place.enums.PlaceType +import busanVibe.busan.domain.tourApi.service.TourCommandService +import io.swagger.v3.oas.annotations.Operation +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/tour-api") +class TourAPIController( + private val tourCommandService: TourCommandService +) { + + @PostMapping("/festivals") +// @Operation(hidden = true) + fun saveFestivals(){ + tourCommandService.syncFestivalsFromApi() + } + + @PostMapping("/place") + fun savePlace(@RequestParam("place-type") placeType: PlaceType ){ + tourCommandService.getPlace(placeType) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourFestivalDTO.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourFestivalDTO.kt new file mode 100644 index 0000000..31f4ed3 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourFestivalDTO.kt @@ -0,0 +1,46 @@ +package busanVibe.busan.domain.tourApi.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +data class FestivalApiResponse( + val getFestivalKr: FestivalData +) + +data class FestivalData( + val header: FestivalHeader, + val item: List, + val numOfRows: Int, + val pageNo: Int, + val totalCount: Int +) + +data class FestivalHeader( + val code: String, + val message: String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class FestivalItem( + val UC_SEQ: Long, + val MAIN_TITLE: String?, + val GUGUN_NM: String?, + val LAT: Double?, + val LNG: Double?, + val PLACE: String?, + val TITLE: String?, + val SUBTITLE: String?, + val MAIN_PLACE: String?, + val ADDR1: String?, + val ADDR2: String?, + val CNTCT_TEL: String?, + val HOMEPAGE_URL: String?, + val TRFC_INFO: String?, + val USAGE_DAY: String?, // 운영기간 + val USAGE_DAY_WEEK_AND_TIME: String?, // 이용요일 및 시간 + val USAGE_AMOUNT: String?, // 이용요금 + val MAIN_IMG_NORMAL: String?, + val MAIN_IMG_THUMB: String?, + val ITEMCNTNTS: String?, // 상세내용 + val MIDDLE_SIZE_RM1: String? // 편의시설 +) + diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceCommonDTO.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceCommonDTO.kt new file mode 100644 index 0000000..82883b8 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceCommonDTO.kt @@ -0,0 +1,79 @@ +package busanVibe.busan.domain.tourApi.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import java.math.BigDecimal + +data class PlaceCommonApiWrapper( + val response: PlaceCommonApiResponse +) + +data class PlaceCommonApiResponse( + val header: PlaceCommonApiHeader?, + val body: PlaceCommonBody? +) + +data class PlaceCommonApiHeader( + @JsonProperty("resultCode") + val resultCode: String, + + @JsonProperty("resultMsg") + val resultMsg: String +) + +data class PlaceCommonBody( + val items: PlaceCommonApiItems, + val numOfRows: Int, + val pageNo: Int, + val totalCount: Int +) + +data class PlaceCommonApiItems( + val item: List +) + +data class PlaceCommonApiItem( + @JsonProperty("contentid") + val contentId: Long, + + @JsonProperty("contenttypeid") + val contentTypeId: Int? = null, + + val title: String, + + val createdtime: String? = null, + val modifiedtime: String? = null, + + val tel: String? = null, + val telname: String? = null, + val homepage: String? = null, + + @JsonProperty("firstimage") + val firstImage: String? = null, + + @JsonProperty("firstimage2") + val firstImage2: String? = null, + + val cpyrhtDivCd: String? = null, + val areacode: Int? = null, + val sigungucode: Int? = null, + val lDongRegnCd: Int? = null, + val lDongSignguCd: Int? = null, + val lclsSystm1: String? = null, + val lclsSystm2: String? = null, + val lclsSystm3: String? = null, + val cat1: String? = null, + val cat2: String? = null, + val cat3: String? = null, + val addr1: String? = null, + val addr2: String? = null, + val zipcode: String? = null, + + @JsonProperty("mapx") + val mapX: BigDecimal? = null, + + @JsonProperty("mapy") + val mapY: BigDecimal? = null, + + val mlevel: Int? = null, + val overview: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceIntroDTO.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceIntroDTO.kt new file mode 100644 index 0000000..b86f92b --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceIntroDTO.kt @@ -0,0 +1,133 @@ +package busanVibe.busan.domain.tourApi.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class PlaceIntroductionResponseWrapper( + val response: PlaceIntroductionResponse +) + +data class PlaceIntroductionResponse( + val header: PlaceIntroductionHeader?, + val body: PlaceIntroductionBody? +) + +data class PlaceIntroductionHeader( + @JsonProperty("resultCode") + val resultCode: String, + + @JsonProperty("resultMsg") + val resultMsg: String +) + +data class PlaceIntroductionBody( + val items: PlaceIntroductionItems, + val numOfRows: Int, + val pageNo: Int, + val totalCount: Int +) + +data class PlaceIntroductionItems( + val item: List +) + +data class PlaceIntroductionItem( + // 공통 + @JsonProperty("contentid") + val contentId: String?, + @JsonProperty("contenttypeid") + val contentTypeId: String?, + + // 음식점 + @JsonProperty("firstmenu") + val firstMenu: String?, + @JsonProperty("treatmenu") + val treatMenu: String?, + @JsonProperty("opentimefood") + val openTimeFood: String?, + @JsonProperty("restdatefood") + val restDateFood: String?, + @JsonProperty("parkingfood") + val parkingFood: String?, + @JsonProperty("chkcreditcardfood") + val chkCreditCardFood: String?, + @JsonProperty("reservationfood") + val reservationFood: String?, + @JsonProperty("infocenterfood") + val infoCenterFood: String?, + @JsonProperty("scalefood") + val scaleFood: String?, + val seat: String?, + val smoking: String?, + @JsonProperty("kidsfacility") + val kidsFacility: String?, + + // 숙박 + @JsonProperty("roomcount") + val roomCount: String?, + @JsonProperty("roomtype") + val roomType: String?, + @JsonProperty("checkintime") + val checkInTime: String?, + @JsonProperty("checkouttime") + val checkOutTime: String?, + @JsonProperty("parkinglodging") + val parkingLodging: String?, + @JsonProperty("infocenterlodging") + val infoCenterLodging: String?, + @JsonProperty("reservationlodging") + val reservationLodging: String?, + + // 축제/행사 + @JsonProperty("eventstartdate") + val eventStartDate: String?, + @JsonProperty("eventenddate") + val eventEndDate: String?, + @JsonProperty("eventplace") + val eventPlace: String?, + @JsonProperty("eventhomepage") + val eventHomepage: String?, + val sponsor1: String?, + @JsonProperty("sponsor1tel") + val sponsor1Tel: String?, + + // 레포츠 + @JsonProperty("usetimeleports") + val useTimeLeports: String?, + @JsonProperty("parkingleports") + val parkingLeports: String?, + + // 쇼핑 + @JsonProperty("opendateshopping") + val openDateShopping: String?, + @JsonProperty("restdateshopping") + val restDateShopping: String?, + @JsonProperty("opentime") + val openTime: String?, + @JsonProperty("parkingshopping") + val parkingShopping: String?, + @JsonProperty("saleitem") + val saleItem: String?, + + // 문화시설 + @JsonProperty("usetimeculture") + val useTimeCulture: String?, + @JsonProperty("restdateculture") + val restDateCulture: String?, + @JsonProperty("infocenterculture") + val infoCenterCulture: String?, + @JsonProperty("parkingculture") + val parkingCulture: String?, + + // 공통 + @JsonProperty("accomcount") + val accomCount: String?, + @JsonProperty("expagerange") + val expAgeRange: String?, + @JsonProperty("infocenter") + val infoCenter: String?, + @JsonProperty("usetime") + val useTime: String?, + @JsonProperty("restdate") + val restDate: String?, + val parking: String? +) diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceRegionDTO.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceRegionDTO.kt new file mode 100644 index 0000000..6309b4a --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/dto/TourPlaceRegionDTO.kt @@ -0,0 +1,61 @@ +package busanVibe.busan.domain.tourApi.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class PlaceApiResponseWrapper( + val response: PlaceApiResponse +) + +data class PlaceApiResponse( + val header: PlaceApiHeader?, + val body: PlaceApiBody? +) + +data class PlaceApiHeader( + val resultCode: String, + val resultMsg: String +) + +data class PlaceApiBody( + @JsonProperty("items") + val items: PlaceApiItems?, + @JsonProperty("numOfRows") + val numOfRows: Int?, + @JsonProperty("pageNo") + val pageNo: Int?, + @JsonProperty("totalCount") + val totalCount: Int? +) + +data class PlaceApiItems( + @JsonProperty("item") + val item: List +) + +data class PlaceApiItem( + @JsonProperty("lclsSystm3") val lclsSystm3: String?, + @JsonProperty("firstimage") val firstImage: String?, + @JsonProperty("firstimage2") val firstImage2: String?, + @JsonProperty("mapx") val mapX: String?, + @JsonProperty("mapy") val mapY: String?, + @JsonProperty("mlevel") val mLevel: String?, + @JsonProperty("addr2") val addr2: String?, + @JsonProperty("areacode") val areaCode: String?, + @JsonProperty("modifiedtime") val modifiedTime: String?, + @JsonProperty("cpyrhtDivCd") val cpyrhtDivCd: String?, + @JsonProperty("cat1") val cat1: String?, + @JsonProperty("sigungucode") val sigunguCode: String?, + @JsonProperty("tel") val tel: String?, + @JsonProperty("title") val title: String, + @JsonProperty("addr1") val addr1: String?, + @JsonProperty("cat2") val cat2: String?, + @JsonProperty("cat3") val cat3: String?, + @JsonProperty("contentid") val contentId: Long, + @JsonProperty("contenttypeid") val contentTypeId: String?, + @JsonProperty("createdtime") val createdTime: String?, + @JsonProperty("zipcode") val zipCode: String?, + @JsonProperty("lDongRegnCd") val lDongRegnCd: String?, + @JsonProperty("lDongSignguCd") val lDongSignguCd: String?, + @JsonProperty("lclsSystm1") val lclsSystm1: String?, + @JsonProperty("lclsSystm2") val lclsSystm2: String? +) diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/enums/TourPlaceType.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/enums/TourPlaceType.kt new file mode 100644 index 0000000..479ed20 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/enums/TourPlaceType.kt @@ -0,0 +1,11 @@ +package busanVibe.busan.domain.tourApi.enums + +enum class TourPlaceType ( + val typeCode: String +){ + + SIGHT("12"), + CULTURE("14"), + RESTAURANT("39") + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt new file mode 100644 index 0000000..5d8a74b --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt @@ -0,0 +1,121 @@ +package busanVibe.busan.domain.tourApi.service + +import busanVibe.busan.domain.festival.repository.FestivalRepository +import busanVibe.busan.domain.place.domain.Place +import busanVibe.busan.domain.place.enums.PlaceType +import busanVibe.busan.domain.place.repository.PlaceJdbcRepository +import busanVibe.busan.domain.place.repository.PlaceRepository +import busanVibe.busan.domain.tourApi.dto.PlaceIntroductionItem +import busanVibe.busan.domain.tourApi.util.TourFestivalConverter +import busanVibe.busan.domain.tourApi.util.TourFestivalUtil +import busanVibe.busan.domain.tourApi.util.TourPlaceUtil +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal + +@Service +class TourCommandService( + private val festivalRepository: FestivalRepository, + private val placeRepository: PlaceRepository, + private val placeJdbcRepository: PlaceJdbcRepository, + private val tourFestivalUtil: TourFestivalUtil, + private val tourPlaceUtil: TourPlaceUtil, +) { + + private val noInfo: String = "정보 없음" + private val log: Logger = LoggerFactory.getLogger(TourCommandService::class.java) + + fun syncFestivalsFromApi() { + val converter = TourFestivalConverter() + val festivals = tourFestivalUtil.getFestivals().map { converter.run { it.toEntity() } } + festivalRepository.saveAll(festivals) + } + + + @Transactional + fun getPlace(placeType: PlaceType) { + val apiResponse = tourPlaceUtil.getPlace(placeType).response + val items = apiResponse.body?.items?.item + + val placeList : MutableList = mutableListOf() + + items?.forEach { apiItem -> + + val detailResponse = tourPlaceUtil.getPlaceDetail(apiItem.contentId).response + val introResponse = tourPlaceUtil.getPlaceIntro( + apiItem.contentId, + apiItem.contentTypeId.toString() + ).response + + val detailItem = detailResponse.body?.items?.item?.firstOrNull() + val introItem = introResponse.body?.items?.item?.firstOrNull() + + val place = Place( + contentId = apiItem.contentId, + name = apiItem.title.orNoInfo(), + type = placeType, + latitude = apiItem.mapY?.toBigDecimal(), + longitude = apiItem.mapX?.toBigDecimal(), + address = apiItem.addr1.orNoInfo(), + introduction = detailItem?.overview.orNoInfo(), + phone = listOf(apiItem.tel, detailItem?.tel, getCenter(placeType, introItem)) + .firstOrNull { !it.isNullOrBlank() } + .orNoInfo(), + useTime = getUseTime(placeType, introItem).orNoInfo(), + restDate = getRest(placeType, introItem).orNoInfo(), + reviews = emptyList(), + placeLikes = emptySet(), + openTime = null, + placeImages = mutableSetOf(), + visitorDistribution = null, + ) + + + // 이미지 추가 (firstimage, firstimage2 등 있을 수 있음) + apiItem.firstImage?.let { place.addImage(it) } + apiItem.firstImage2?.let { place.addImage(it) } + +// 저장 (중복 방지) +// if (!placeRepository.existsByContentId(apiItem.contentId)) { +// placeRepository.save(place) +// } + placeList.add(place) + } +// placeJdbcRepository.saveAll(placeList) + placeRepository.saveAll(placeList) + } + + private fun getUseTime(placeType: PlaceType, introItem: PlaceIntroductionItem?): String = + when (placeType) { + PlaceType.ALL -> introItem?.useTime + PlaceType.CAFE -> introItem?.openTimeFood + PlaceType.RESTAURANT -> introItem?.openTimeFood + PlaceType.SIGHT -> introItem?.useTime + PlaceType.CULTURE -> introItem?.useTimeCulture + }.orNoInfo() + + private fun getRest(placeType: PlaceType, introItem: PlaceIntroductionItem?): String = + when (placeType) { + PlaceType.ALL -> introItem?.restDate + PlaceType.CAFE -> introItem?.restDateFood + PlaceType.RESTAURANT -> introItem?.restDateFood + PlaceType.SIGHT -> introItem?.restDate + PlaceType.CULTURE -> introItem?.restDateCulture + }.orNoInfo() + + private fun getCenter(placeType: PlaceType, introItem: PlaceIntroductionItem?): String = + when (placeType) { + PlaceType.ALL -> introItem?.infoCenter + PlaceType.CAFE -> introItem?.infoCenterFood + PlaceType.RESTAURANT -> introItem?.infoCenterFood + PlaceType.SIGHT -> introItem?.infoCenter + PlaceType.CULTURE -> introItem?.infoCenterCulture + }.orNoInfo() + + + private fun String?.orNoInfo(): String = if (this.isNullOrBlank()) noInfo else this + private fun BigDecimal?.orZero(): BigDecimal = this ?: BigDecimal.ZERO + +} diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalConverter.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalConverter.kt new file mode 100644 index 0000000..f1b3438 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalConverter.kt @@ -0,0 +1,135 @@ +package busanVibe.busan.domain.tourApi.util + +import busanVibe.busan.domain.festival.domain.Festival +import busanVibe.busan.domain.festival.domain.FestivalImage +import busanVibe.busan.domain.festival.enums.FestivalStatus +import busanVibe.busan.domain.tourApi.dto.FestivalItem +import java.time.LocalDate +import java.util.Date +import java.util.regex.Pattern + +class TourFestivalConverter { + + fun FestivalItem.toEntity(): Festival { + val startDate = parseStartDate(USAGE_DAY_WEEK_AND_TIME) + val endDate = parseEndDate(USAGE_DAY_WEEK_AND_TIME) + + val festival = Festival( + name = MAIN_TITLE ?: "이름없음", + startDate = startDate?: Date(), + endDate = endDate?: Date(), + place = MAIN_PLACE ?: "장소없음", + introduction = ITEMCNTNTS ?: "", + fee = USAGE_AMOUNT?: "정보없음", + phone = CNTCT_TEL ?: "정보없음", + siteUrl = HOMEPAGE_URL ?: "정보없음", + status = getStatus(USAGE_DAY_WEEK_AND_TIME?:""), + festivalLikes = emptySet(), + contentId = UC_SEQ + ) + + println("MAIN_IMG_NORMAL = ${MAIN_IMG_NORMAL}") + // 이미지 추가 + MAIN_IMG_NORMAL?.let { + festival.addFestivalImage(FestivalImage(imgUrl = it, festival = festival)) + } + MAIN_IMG_THUMB?.let { + festival.addFestivalImage(FestivalImage(imgUrl = it, festival = festival)) + } + + return festival + } + + fun parseStartDate(dateStr: String?): Date? { + if (dateStr.isNullOrBlank()) return null + val start = tryParseStartEnd(dateStr, true) + return start?.let { Date.from(it.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()) } + } + + fun parseEndDate(dateStr: String?): Date? { + if (dateStr.isNullOrBlank()) return null + val end = tryParseStartEnd(dateStr, false) + return end?.let { Date.from(it.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()) } + } + + private fun tryParseStartEnd(dateStr: String, isStart: Boolean): LocalDate? { + val cleaned = dateStr.replace("\\(.*?\\)".toRegex(), "") // 요일 제거 + val patterns = listOf( + "(\\d{4})\\.\\s?(\\d{1,2})\\.\\s?(\\d{1,2})\\s?~\\s?(\\d{1,2})", // 2025. 5. 30. ~ 5. 31. + "(\\d{4})\\.\\s?(\\d{1,2})\\.\\s?(\\d{1,2})\\s?~\\s?(\\d{4})\\.\\s?(\\d{1,2})\\.\\s?(\\d{1,2})", // 2024. 11. 15. ~ 2025. 02. 02 + "(\\d{4})년\\s?(\\d{1,2})월" // 2024년 10월 예정 + ) + + for (pattern in patterns) { + val regex = Pattern.compile(pattern) + val matcher = regex.matcher(cleaned) + if (matcher.find()) { + return when (pattern) { + patterns[0] -> { // 2025. 5. 30. ~ 5. 31. + val year = matcher.group(1).toInt() + val month = matcher.group(2).toInt() + val dayStart = matcher.group(3).toInt() + val dayEnd = matcher.group(4).toInt() + if (isStart) LocalDate.of(year, month, dayStart) + else LocalDate.of(year, month, dayEnd) + } + patterns[1] -> { // 2024. 11. 15. ~ 2025. 02. 02 + if (isStart) LocalDate.of(matcher.group(1).toInt(), matcher.group(2).toInt(), matcher.group(3).toInt()) + else LocalDate.of(matcher.group(4).toInt(), matcher.group(5).toInt(), matcher.group(6).toInt()) + } + patterns[2] -> { // 2024년 10월 예정 + val year = matcher.group(1).toInt() + val month = matcher.group(2).toInt() + if (isStart) LocalDate.of(year, month, 1) + else LocalDate.of(year, month, LocalDate.of(year, month, 1).lengthOfMonth()) + } + else -> null + } + } + } + return null + } + + + fun getStatus(dateStr: String): FestivalStatus { + + if (dateStr.equals("")) { + return FestivalStatus.UNKNOWN + } + + val now = LocalDate.now() + + // 1. 정확한 기간 표시 (yyyy. MM. dd. ~ yyyy. MM. dd.) + val fullDateRangeRegex = """(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\..*~\s*(\d{4})?\.\s*(\d{1,2})\.\s*(\d{1,2})""".toRegex() + + // 2. 월 단위, 예정 표시 (ex: 2024년 10월 예정, 2025.8월 중 (예정)) + val monthYearRegex = """(\d{4})[년.]?\s*(\d{1,2})[월.]?.*예정""".toRegex() + + fullDateRangeRegex.find(dateStr)?.let { match -> + val startYear = match.groupValues[1].toInt() + val startMonth = match.groupValues[2].toInt() + val startDay = match.groupValues[3].toInt() + val endYear = match.groupValues[4].ifEmpty { match.groupValues[1] }.toInt() + val endMonth = match.groupValues[5].toInt() + val endDay = match.groupValues[6].toInt() + + val startDate = LocalDate.of(startYear, startMonth, startDay) + val endDate = LocalDate.of(endYear, endMonth, endDay) + + return when { + now.isBefore(startDate) -> FestivalStatus.UPCOMING + now.isAfter(endDate) -> FestivalStatus.COMPLETE + else -> FestivalStatus.IN_PROGRESS + } + } + + monthYearRegex.find(dateStr)?.let { _ -> + return FestivalStatus.UPCOMING + } + + // 기타 알 수 없는 형식이면 UNKNOWN + return FestivalStatus.UNKNOWN + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalUtil.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalUtil.kt new file mode 100644 index 0000000..d499fab --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalUtil.kt @@ -0,0 +1,49 @@ +package busanVibe.busan.domain.tourApi.util + +import busanVibe.busan.domain.tourApi.dto.FestivalApiResponse +import busanVibe.busan.domain.tourApi.dto.FestivalItem +import java.net.HttpURLConnection +import java.net.URL +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +class TourFestivalUtil { + + @Value("\${tourAPI.key}") + lateinit var tourApiKey: String + + fun getFestivals(): List { + + val pageNum = "1" + val pageSize = "100" + + val urlBuilder = StringBuilder("http://apis.data.go.kr/6260000/FestivalService/getFestivalKr") + urlBuilder.append("?ServiceKey=") + urlBuilder.append(tourApiKey) + urlBuilder.append("&pageNo=") + urlBuilder.append(pageNum) + urlBuilder.append("&numOfRows=") + urlBuilder.append(pageSize) + urlBuilder.append("&resultType=json") + + + println("url = ${urlBuilder.toString()}") + + val conn = URL(urlBuilder.toString()).openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.setRequestProperty("Content-type", "application/json") + + val response = conn.inputStream.bufferedReader().use { it.readText() } + conn.disconnect() + + val mapper = jacksonObjectMapper() + val apiResponse: FestivalApiResponse = mapper.readValue(response) + + return apiResponse.getFestivalKr.item + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourPlaceUtil.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourPlaceUtil.kt new file mode 100644 index 0000000..e33dab2 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourPlaceUtil.kt @@ -0,0 +1,103 @@ +package busanVibe.busan.domain.tourApi.util + +import busanVibe.busan.domain.place.enums.PlaceType +import busanVibe.busan.domain.tourApi.dto.PlaceApiResponseWrapper +import busanVibe.busan.domain.tourApi.dto.PlaceCommonApiWrapper +import busanVibe.busan.domain.tourApi.dto.PlaceIntroductionResponseWrapper +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import java.net.URI + +@Component +class TourPlaceUtil( + private val webClientBuilder: WebClient.Builder, +) { + + @Value("\${tourAPI.key}") + lateinit var tourApiKey: String + + // SLF4J 로그 세팅 + private val log: Logger = LoggerFactory.getLogger(TourPlaceUtil::class.java) + + // TOUR API 요청 파라미터 + private val mobileOs:String = "AND" + private val mobileApp: String = "busanvibe" + private val numOfRows: String = "10" + + // webclient 응답 버퍼 증가 + private val strategies = org.springframework.web.reactive.function.client.ExchangeStrategies.builder() + .codecs { configurer -> + configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) // 16MB로 늘림 + } + .build() + + // json 변환 위한 objectMapper + val objectMapper = jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + fun getPlace(placeType: PlaceType): PlaceApiResponseWrapper { + val placeTypeCode = placeType.tourApiTypeId + + val url = StringBuilder("https://apis.data.go.kr/B551011/KorService2/areaBasedList2") + .append("?numOfRows=").append(numOfRows) + .append("&pageNo=0") + .append("&MobileOS=").append(mobileOs) + .append("&MobileApp=").append(mobileApp) + .append("&contentTypeId=").append(placeTypeCode) + .append("&areaCode=6") // 부산 지역 + .append("&_type=json") // JSON 요청 + .append("&serviceKey=").append(tourApiKey) + .toString() + + val json = makeJson(url) ?: throw RuntimeException("JSON response is null") + log.info("[TourPlaceUtil] Tour API 지역기반 API 조회 결과 json = {}", json) + return objectMapper.readValue(json, PlaceApiResponseWrapper::class.java) + } + + fun getPlaceDetail(contentId: Long): PlaceCommonApiWrapper { + val url = StringBuilder("https://apis.data.go.kr/B551011/KorService2/detailCommon2") + .append("?MobileOS=").append(mobileOs) + .append("&MobileApp=").append(mobileApp) + .append("&contentId=").append(contentId) + .append("&_type=json") + .append("&serviceKey=").append(tourApiKey) + .toString() + + val json = makeJson(url) ?: throw RuntimeException("JSON response is null") + println("json = ${json}") + return objectMapper.readValue(json, PlaceCommonApiWrapper::class.java) + } + + fun getPlaceIntro(contentId: Long, contentTypeId: String): PlaceIntroductionResponseWrapper { + val url = StringBuilder("https://apis.data.go.kr/B551011/KorService2/detailIntro2") + .append("?MobileOS=").append(mobileOs) + .append("&MobileApp=").append(mobileApp) + .append("&contentId=").append(contentId) + .append("&contentTypeId=").append(contentTypeId) + .append("&_type=json") + .append("&serviceKey=").append(tourApiKey) + .toString() + + val json = makeJson(url) ?: throw RuntimeException("JSON response is null") + return objectMapper.readValue(json, PlaceIntroductionResponseWrapper::class.java) + } + + private fun makeJson(url: String): String? { + + return webClientBuilder + .exchangeStrategies(strategies) + .build() + .get() + .uri(URI.create(url)) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(String::class.java) + .block() + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b5ce237..7274e5a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,10 +4,8 @@ spring: logging: level: root: info - org.springframework.web.socket: DEBUG - org.springframework.messaging: DEBUG - org.springframework.messaging.converter: DEBUG - + org.springframework.jdbc.core: DEBUG # JdbcTemplate 쿼리 로그 + org.springframework.jdbc.datasource: DEBUG docker: compose: @@ -63,5 +61,8 @@ openai: model: gpt-4o secret-key: ${OPEN_AI_KEY} +tourAPI: + key: ${TOUR_API_KEY} +