From b9aba35ae1926afebc2599c4c43ecc4d157305c7 Mon Sep 17 00:00:00 2001 From: YoungJun Park Date: Sat, 9 Nov 2024 17:28:48 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add:=20real-time=20weather=20update?= =?UTF-8?q?=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../kotlin/nexters/weski/WeskiApplication.kt | 2 + .../weski/batch/ExternalWeatherService.kt | 153 ++++++++++++++++++ .../nexters/weski/batch/WeatherScheduler.kt | 14 ++ .../nexters/weski/ski_resort/SkiResort.kt | 8 +- .../nexters/weski/weather/CurrentWeather.kt | 9 +- .../nexters/weski/weather/WeatherDto.kt | 2 +- src/main/resources/application.yml | 4 + 8 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/nexters/weski/batch/ExternalWeatherService.kt create mode 100644 src/main/kotlin/nexters/weski/batch/WeatherScheduler.kt diff --git a/build.gradle.kts b/build.gradle.kts index 0f9d0d8..5f80209 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") + implementation("org.springframework.boot:spring-boot-starter-quartz") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.mockito:mockito-core:4.11.0") diff --git a/src/main/kotlin/nexters/weski/WeskiApplication.kt b/src/main/kotlin/nexters/weski/WeskiApplication.kt index cdfcba9..80ee88e 100644 --- a/src/main/kotlin/nexters/weski/WeskiApplication.kt +++ b/src/main/kotlin/nexters/weski/WeskiApplication.kt @@ -2,8 +2,10 @@ package nexters.weski import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication(scanBasePackages = ["nexters.weski"]) +@EnableScheduling class WeskiApplication fun main(args: Array) { diff --git a/src/main/kotlin/nexters/weski/batch/ExternalWeatherService.kt b/src/main/kotlin/nexters/weski/batch/ExternalWeatherService.kt new file mode 100644 index 0000000..85ef744 --- /dev/null +++ b/src/main/kotlin/nexters/weski/batch/ExternalWeatherService.kt @@ -0,0 +1,153 @@ +package nexters.weski.batch + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import jakarta.transaction.Transactional +import nexters.weski.ski_resort.SkiResort +import nexters.weski.ski_resort.SkiResortRepository +import nexters.weski.weather.CurrentWeather +import nexters.weski.weather.CurrentWeatherRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.client.RestTemplate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.pow + +@Service +class ExternalWeatherService( + private val currentWeatherRepository: CurrentWeatherRepository, + private val skiResortRepository: SkiResortRepository +) { + @Value("\${weather.api.key}") + lateinit var apiKey: String + + val restTemplate = RestTemplate() + val objectMapper = jacksonObjectMapper() + + @Transactional + fun updateCurrentWeather() { + val baseDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + val baseTime = getBaseTime() + skiResortRepository.findAll().forEach { resort -> + val url = "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst" + + "?serviceKey=$apiKey" + + "&pageNo=1" + + "&numOfRows=1000" + + "&dataType=JSON" + + "&base_date=$baseDate" + + "&base_time=$baseTime" + + "&nx=${resort.xCoordinate}" + + "&ny=${resort.yCoordinate}" + val response = restTemplate.getForObject(url, String::class.java) + val weatherData = parseWeatherData(response) + val newCurrentWeather = mapToCurrentWeather(weatherData, resort) + + // 기존 데이터 조회 + val existingWeather = currentWeatherRepository.findBySkiResortResortId(resort.resortId) + + if (existingWeather != null) { + // 기존 데이터의 ID를 사용하여 새로운 엔티티 생성 + val updatedWeather = newCurrentWeather.copy(id = existingWeather.id) + currentWeatherRepository.save(updatedWeather) + } else { + // 새로운 데이터 삽입 + currentWeatherRepository.save(newCurrentWeather) + } + } + } + + private fun getBaseTime(): String { + val now = LocalDateTime.now().minusHours(1) + val hour = now.hour.toString().padStart(2, '0') + return "${hour}00" + } + + private fun parseWeatherData(response: String?): Map { + val data = mutableMapOf() + + response?.let { + val rootNode = objectMapper.readTree(it) + val items = rootNode["response"]["body"]["items"]["item"] + + items.forEach { item -> + val category = item["category"].asText() + val value = item["obsrValue"].asText() + data[category] = value + } + } + + return data + } + + private fun mapToCurrentWeather( + data: Map, + resort: SkiResort + ): CurrentWeather { + val temperature = data["T1H"]?.toDoubleOrNull()?.toInt() ?: 0 + val windSpeed = data["WSD"]?.toDoubleOrNull() ?: 0.0 + val feelsLike = calculateFeelsLike(temperature, windSpeed) + val condition = determineCondition(data) + val description = generateDescription(condition, temperature) + + return CurrentWeather( + temperature = temperature, + maxTemp = data["TMX"]?.toDoubleOrNull()?.toInt() ?: temperature, + minTemp = data["TMN"]?.toDoubleOrNull()?.toInt() ?: temperature, + feelsLike = feelsLike, + condition = condition, + description = description, + skiResort = resort + ) + } + + private fun calculateFeelsLike(temperature: Int, windSpeed: Double): Int { + return if (temperature <= 10 && windSpeed >= 4.8) { + val feelsLike = + 13.12 + 0.6215 * temperature - 11.37 * windSpeed.pow(0.16) + 0.3965 * temperature * windSpeed.pow( + 0.16 + ) + feelsLike.toInt() + } else { + temperature + } + } + + private fun determineCondition(data: Map): String { + val pty = data["PTY"]?.toIntOrNull() ?: 0 + val sky = data["SKY"]?.toIntOrNull() ?: 1 + + return when { + pty == 1 || pty == 4 -> "비" + pty == 2 -> "비/눈" + pty == 3 -> "눈" + sky == 1 -> "맑음" + sky == 3 -> "구름많음" + sky == 4 -> "흐림" + else -> "맑음" + } + } + + private fun generateDescription(condition: String, temperature: Int): String { + val prefix = when (condition) { + "맑음" -> "화창하고" + "구름많음" -> "구름이 많고" + "흐림" -> "흐리고" + "비" -> "비가 오고" + "비/눈" -> "눈비가 내리고" + "눈" -> "눈이 오고" + else -> "" + } + + val postfix = when { + temperature <= -15 -> "매우 추워요" + temperature in -14..-10 -> "다소 추워요" + temperature in -9..-5 -> "적당한 온도에요" + temperature in -4..0 -> "조금 따뜻해요" + temperature in 1..5 -> "따뜻해요" + temperature in 6..10 -> "다소 더워요" + else -> "더워요" + } + + return "$prefix $postfix" + } +} \ No newline at end of file diff --git a/src/main/kotlin/nexters/weski/batch/WeatherScheduler.kt b/src/main/kotlin/nexters/weski/batch/WeatherScheduler.kt new file mode 100644 index 0000000..676bd32 --- /dev/null +++ b/src/main/kotlin/nexters/weski/batch/WeatherScheduler.kt @@ -0,0 +1,14 @@ +package nexters.weski.batch + +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class WeatherScheduler( + private val externalWeatherService: ExternalWeatherService +) { + @Scheduled(cron = "0 0 * * * ?") + fun scheduleWeatherUpdate() { + externalWeatherService.updateCurrentWeather() + } +} \ No newline at end of file diff --git a/src/main/kotlin/nexters/weski/ski_resort/SkiResort.kt b/src/main/kotlin/nexters/weski/ski_resort/SkiResort.kt index c5158d8..f549e4e 100644 --- a/src/main/kotlin/nexters/weski/ski_resort/SkiResort.kt +++ b/src/main/kotlin/nexters/weski/ski_resort/SkiResort.kt @@ -4,6 +4,7 @@ import jakarta.persistence.* import nexters.weski.common.BaseEntity import nexters.weski.slope.Slope import nexters.weski.webcam.Webcam +import java.time.LocalDate @Entity @Table(name = "ski_resorts") @@ -17,9 +18,9 @@ data class SkiResort( @Enumerated(EnumType.STRING) val status: ResortStatus, - val openingDate: java.time.LocalDate? = null, + val openingDate: LocalDate? = null, - val closingDate: java.time.LocalDate? = null, + val closingDate: LocalDate? = null, val openSlopes: Int = 0, @@ -30,6 +31,9 @@ data class SkiResort( val lateNightOperatingHours: String? = null, val dawnOperatingHours: String? = null, val midnightOperatingHours: String? = null, + val snowfallTime: String? = null, + val xCoordinate: String, + val yCoordinate: String, @OneToMany(mappedBy = "skiResort") val slopes: List = emptyList(), diff --git a/src/main/kotlin/nexters/weski/weather/CurrentWeather.kt b/src/main/kotlin/nexters/weski/weather/CurrentWeather.kt index 6f55cdb..247d95a 100644 --- a/src/main/kotlin/nexters/weski/weather/CurrentWeather.kt +++ b/src/main/kotlin/nexters/weski/weather/CurrentWeather.kt @@ -7,18 +7,19 @@ import nexters.weski.ski_resort.SkiResort @Entity @Table(name = "current_weather") data class CurrentWeather( - @Id - val resortId: Long, + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, val temperature: Int, val maxTemp: Int, val minTemp: Int, val feelsLike: Int, val description: String, + + @Column(name = "`condition`") val condition: String, @OneToOne - @MapsId - @JoinColumn(name = "resort_id") + @JoinColumn(name = "resort_id", unique = true) val skiResort: SkiResort ) : BaseEntity() diff --git a/src/main/kotlin/nexters/weski/weather/WeatherDto.kt b/src/main/kotlin/nexters/weski/weather/WeatherDto.kt index 772b2db..fd8ba3f 100644 --- a/src/main/kotlin/nexters/weski/weather/WeatherDto.kt +++ b/src/main/kotlin/nexters/weski/weather/WeatherDto.kt @@ -15,7 +15,7 @@ data class WeatherDto( dailyWeather: List ): WeatherDto { return WeatherDto( - resortId = currentWeather.resortId, + resortId = currentWeather.skiResort.resortId, currentWeather = CurrentWeatherDto.fromEntity(currentWeather), hourlyWeather = hourlyWeather.map { HourlyWeatherDto.fromEntity(it) }, weeklyWeather = dailyWeather.map { DailyWeatherDto.fromEntity(it) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e97a28e..b31faaf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,3 +21,7 @@ springdoc: path: /v3/api-docs swagger-ui: path: /swagger-ui.html +weather: + api: + key: p6zNXOJrrBY4cuX7OYtdDMtmR8hiGeUaBLf0z6BXnm/qniV8wB0SuPwBgqKDTKV/24EW7xiRY3DCS21Ess/42Q== +