Skip to content

Commit 3a8d54d

Browse files
authored
Merge pull request #19 from Nexters/feature/18-hourly-weather-update
[#18] 시간대별 날씨 업데이트 batch
2 parents 205e985 + 47f0cec commit 3a8d54d

File tree

9 files changed

+253
-52
lines changed

9 files changed

+253
-52
lines changed

src/main/kotlin/nexters/weski/batch/ExternalWeatherService.kt

Lines changed: 226 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
55
import jakarta.transaction.Transactional
66
import nexters.weski.ski_resort.SkiResort
77
import nexters.weski.ski_resort.SkiResortRepository
8-
import nexters.weski.weather.CurrentWeather
9-
import nexters.weski.weather.CurrentWeatherRepository
10-
import nexters.weski.weather.DailyWeather
11-
import nexters.weski.weather.DailyWeatherRepository
8+
import nexters.weski.weather.*
129
import org.springframework.beans.factory.annotation.Value
1310
import org.springframework.stereotype.Service
1411
import org.springframework.web.client.RestTemplate
@@ -21,6 +18,7 @@ import kotlin.math.pow
2118
class ExternalWeatherService(
2219
private val currentWeatherRepository: CurrentWeatherRepository,
2320
private val dailyWeatherRepository: DailyWeatherRepository,
21+
private val hourlyWeatherRepository: HourlyWeatherRepository,
2422
private val skiResortRepository: SkiResortRepository
2523
) {
2624
@Value("\${weather.api.key}")
@@ -158,7 +156,7 @@ class ExternalWeatherService(
158156

159157
@Transactional
160158
fun updateDailyWeather() {
161-
val baseDateTime = getBaseDateTime()
159+
val baseDateTime = getYesterdayBaseDateTime()
162160
val baseDate = baseDateTime.first
163161
val baseTime = baseDateTime.second
164162

@@ -185,7 +183,7 @@ class ExternalWeatherService(
185183
}
186184
}
187185

188-
private fun getBaseDateTime(): Pair<LocalDate, String> {
186+
private fun getYesterdayBaseDateTime(): Pair<LocalDate, String> {
189187
// 어제 날짜
190188
val yesterday = LocalDate.now().minusDays(1)
191189
val hour = 18 // 18시 기준
@@ -339,10 +337,226 @@ class ExternalWeatherService(
339337
}
340338

341339
@Transactional
342-
fun updateDDayValues() {
343-
// d_day 값이 0인 데이터 삭제
344-
dailyWeatherRepository.deleteByDDay(0)
345-
// 나머지 데이터의 d_day 값을 1씩 감소
346-
dailyWeatherRepository.decrementDDayValues()
340+
fun updateHourlyAndDailyWeather() {
341+
val baseDateTime = getBaseDateTime()
342+
val baseDate = baseDateTime.first
343+
val baseTime = baseDateTime.second
344+
345+
skiResortRepository.findAll().forEach { resort ->
346+
val nx = resort.xCoordinate
347+
val ny = resort.yCoordinate
348+
349+
val url = buildVilageFcstUrl(baseDate, baseTime, nx, ny)
350+
val response = restTemplate.getForObject(url, String::class.java)
351+
val forecastData = parseVilageFcstResponse(response)
352+
353+
// 시간대별 날씨 업데이트
354+
val hourlyWeathers = createHourlyWeather(resort, forecastData)
355+
hourlyWeatherRepository.deleteBySkiResort(resort)
356+
hourlyWeatherRepository.saveAll(hourlyWeathers)
357+
358+
// 주간 날씨 업데이트
359+
updateShortTermDailyWeather(resort, forecastData)
360+
}
361+
}
362+
363+
private fun getBaseDateTime(): Pair<String, String> {
364+
// 전날 23시 return(ex: 20241109 2300)
365+
val yesterday = LocalDateTime.now().minusDays(1)
366+
.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
367+
val baseTime = "2300"
368+
return Pair(yesterday, baseTime)
369+
}
370+
371+
private fun buildVilageFcstUrl(baseDate: String, baseTime: String, nx: String, ny: String): String {
372+
return "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst" +
373+
"?serviceKey=$apiKey" +
374+
"&pageNo=1" +
375+
"&numOfRows=1000" +
376+
"&dataType=JSON" +
377+
"&base_date=$baseDate" +
378+
"&base_time=$baseTime" +
379+
"&nx=$nx" +
380+
"&ny=$ny"
381+
}
382+
383+
private fun parseVilageFcstResponse(response: String?): List<ForecastItem> {
384+
response ?: return emptyList()
385+
val rootNode = objectMapper.readTree(response)
386+
val itemsNode = rootNode["response"]["body"]["items"]["item"]
387+
val items = mutableListOf<ForecastItem>()
388+
389+
itemsNode?.forEach { itemNode ->
390+
val category = itemNode["category"].asText()
391+
val fcstDate = itemNode["fcstDate"].asText()
392+
val fcstTime = itemNode["fcstTime"].asText()
393+
val fcstValue = itemNode["fcstValue"].asText()
394+
items.add(ForecastItem(category, fcstDate, fcstTime, fcstValue))
395+
}
396+
397+
return items
398+
}
399+
400+
data class ForecastItem(
401+
val category: String,
402+
val fcstDate: String,
403+
val fcstTime: String,
404+
val fcstValue: String
405+
)
406+
407+
private fun createHourlyWeather(
408+
resort: SkiResort,
409+
forecastData: List<ForecastItem>
410+
): List<HourlyWeather> {
411+
val hourlyWeathers = mutableListOf<HourlyWeather>()
412+
val timeSlots = generateTimeSlots()
413+
414+
var priority = 1
415+
for (timeSlot in timeSlots) {
416+
val dataForTime = forecastData.filter { it.fcstDate == timeSlot.first && it.fcstTime == timeSlot.second }
417+
if (dataForTime.isEmpty()) continue
418+
419+
val dataMap = dataForTime.groupBy { it.category }.mapValues { it.value.first().fcstValue }
420+
val temperature = dataMap["TMP"]?.toIntOrNull() ?: continue
421+
val precipitationChance = dataMap["POP"]?.toIntOrNull() ?: continue
422+
val sky = dataMap["SKY"]?.toIntOrNull() ?: 1
423+
val pty = dataMap["PTY"]?.toIntOrNull() ?: 0
424+
val condition = determineCondition(sky, pty)
425+
426+
val forecastTime = formatForecastTime(timeSlot.second)
427+
val hourlyWeather = HourlyWeather(
428+
skiResort = resort,
429+
forecastTime = forecastTime,
430+
priority = priority,
431+
temperature = temperature,
432+
precipitationChance = precipitationChance,
433+
condition = condition
434+
)
435+
hourlyWeathers.add(hourlyWeather)
436+
priority++
437+
}
438+
return hourlyWeathers
439+
}
440+
441+
private fun generateTimeSlots(): List<Pair<String, String>> {
442+
val timeSlots = mutableListOf<Pair<String, String>>()
443+
val today = LocalDate.now()
444+
val tomorrow = today.plusDays(1)
445+
val format = DateTimeFormatter.ofPattern("yyyyMMdd")
446+
447+
val times = listOf("0800", "1000", "1200", "1400", "1600", "1800", "2000", "2200", "0000", "0200")
448+
for (time in times) {
449+
val date = if (time == "0000" || time == "0200") tomorrow.format(format) else today.format(format)
450+
timeSlots.add(Pair(date, time))
451+
}
452+
return timeSlots
453+
}
454+
455+
private fun formatForecastTime(fcstTime: String): String {
456+
val hour = fcstTime.substring(0, 2).toInt()
457+
val period = if (hour < 12) "오전" else "오후"
458+
val hourIn12 = if (hour == 0 || hour == 12) 12 else hour % 12
459+
return "$period ${hourIn12}"
460+
}
461+
462+
private fun determineCondition(sky: Int, pty: Int): String {
463+
return when (pty) {
464+
1 -> ""
465+
2 -> "비/눈"
466+
3 -> ""
467+
4 -> "소나기"
468+
else -> when (sky) {
469+
1 -> "맑음"
470+
3 -> "구름많음"
471+
4 -> "흐림"
472+
else -> "맑음"
473+
}
474+
}
475+
}
476+
477+
private fun updateShortTermDailyWeather(
478+
resort: SkiResort,
479+
forecastData: List<ForecastItem>
480+
) {
481+
val today = LocalDate.now()
482+
val tomorrow = today.plusDays(1)
483+
val format = DateTimeFormatter.ofPattern("yyyyMMdd")
484+
485+
val days = listOf(Pair(today, 0), Pair(tomorrow, 1))
486+
days.forEach { (date, dDay) ->
487+
val dateStr = date.format(format)
488+
val dataForDay = forecastData.filter { it.fcstDate == dateStr }
489+
if (dataForDay.isEmpty()) return@forEach
490+
491+
// 최고 강수확률
492+
val popValues = dataForDay.filter { it.category == "POP" }.mapNotNull { it.fcstValue.toIntOrNull() }
493+
val precipitationChance = popValues.maxOrNull() ?: 0
494+
495+
// 가장 나쁜 상태
496+
val conditions = dataForDay.filter { it.category == "SKY" || it.category == "PTY" }
497+
.groupBy { Pair(it.fcstDate, it.fcstTime) }
498+
.map { (_, items) ->
499+
val sky = items.find { it.category == "SKY" }?.fcstValue?.toIntOrNull() ?: 1
500+
val pty = items.find { it.category == "PTY" }?.fcstValue?.toIntOrNull() ?: 0
501+
determineConditionPriority(sky, pty)
502+
}
503+
504+
val worstCondition = conditions.maxByOrNull { it.priority }?.condition ?: "맑음"
505+
506+
// 최저기온과 최고기온 계산
507+
val tmnValues =
508+
dataForDay.filter { it.category == "TMN" }.mapNotNull { it.fcstValue.toDoubleOrNull()?.toInt() }
509+
val tmxValues =
510+
dataForDay.filter { it.category == "TMX" }.mapNotNull { it.fcstValue.toDoubleOrNull()?.toInt() }
511+
512+
val tmpValues =
513+
dataForDay.filter { it.category == "TMP" }.mapNotNull { it.fcstValue.toDoubleOrNull()?.toInt() }
514+
515+
val minTemp = if (tmnValues.isNotEmpty()) tmnValues.minOrNull() ?: 0 else tmpValues.minOrNull() ?: 0
516+
val maxTemp = if (tmxValues.isNotEmpty()) tmxValues.maxOrNull() ?: 0 else tmpValues.maxOrNull() ?: 0
517+
518+
// 주간 날씨 업데이트
519+
val existingWeather = dailyWeatherRepository.findBySkiResortAndDDay(resort, dDay)
520+
if (existingWeather != null) {
521+
existingWeather.forecastDate = date
522+
existingWeather.dayOfWeek = convertDayOfWeek(date.dayOfWeek.name)
523+
existingWeather.precipitationChance = precipitationChance
524+
existingWeather.condition = worstCondition
525+
existingWeather.minTemp = minTemp
526+
existingWeather.maxTemp = maxTemp
527+
dailyWeatherRepository.save(existingWeather)
528+
} else {
529+
val dailyWeather = DailyWeather(
530+
skiResort = resort,
531+
forecastDate = date,
532+
dayOfWeek = convertDayOfWeek(date.dayOfWeek.name),
533+
dDay = dDay,
534+
precipitationChance = precipitationChance,
535+
maxTemp = maxTemp,
536+
minTemp = minTemp,
537+
condition = worstCondition
538+
)
539+
dailyWeatherRepository.save(dailyWeather)
540+
}
541+
}
542+
}
543+
544+
data class ConditionPriority(val condition: String, val priority: Int)
545+
546+
private fun determineConditionPriority(sky: Int, pty: Int): ConditionPriority {
547+
return when (pty) {
548+
3 -> ConditionPriority("", 7)
549+
2 -> ConditionPriority("비/눈", 6)
550+
1 -> ConditionPriority("", 5)
551+
4 -> ConditionPriority("소나기", 4)
552+
0 -> when (sky) {
553+
4 -> ConditionPriority("흐림", 3)
554+
3 -> ConditionPriority("구름많음", 2)
555+
1 -> ConditionPriority("맑음", 1)
556+
else -> ConditionPriority("맑음", 1)
557+
}
558+
559+
else -> ConditionPriority("맑음", 1)
560+
}
347561
}
348-
}
562+
}

src/main/kotlin/nexters/weski/batch/WeatherScheduler.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ class WeatherScheduler(
1414

1515
@Scheduled(cron = "0 30 3 * * *")
1616
fun scheduledDailyWeatherUpdate() {
17-
externalWeatherService.updateDDayValues()
1817
externalWeatherService.updateDailyWeather()
1918
}
20-
}
19+
20+
@Scheduled(cron = "0 10 5 * * *")
21+
fun scheduledHourlyAndDailyUpdate() {
22+
externalWeatherService.updateHourlyAndDailyWeather()
23+
}
24+
}

src/main/kotlin/nexters/weski/weather/DailyWeather.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ data class DailyWeather(
1212
@GeneratedValue(strategy = GenerationType.IDENTITY)
1313
val id: Long = 0,
1414

15-
val forecastDate: LocalDate,
16-
val dayOfWeek: String,
15+
var forecastDate: LocalDate,
16+
var dayOfWeek: String,
1717
val dDay: Int,
18-
val precipitationChance: Int,
19-
val maxTemp: Int,
20-
val minTemp: Int,
18+
var precipitationChance: Int,
19+
var maxTemp: Int,
20+
var minTemp: Int,
2121
@Column(name = "`condition`")
22-
val condition: String,
22+
var condition: String,
2323

2424
@ManyToOne
2525
@JoinColumn(name = "resort_id")
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
package nexters.weski.weather
22

3+
import nexters.weski.ski_resort.SkiResort
34
import org.springframework.data.jpa.repository.JpaRepository
4-
import org.springframework.data.jpa.repository.Modifying
5-
import org.springframework.data.jpa.repository.Query
65

76
interface DailyWeatherRepository : JpaRepository<DailyWeather, Long> {
87
fun findAllBySkiResortResortId(resortId: Long): List<DailyWeather>
98
fun deleteByDDayGreaterThanEqual(dDay: Int)
10-
fun deleteByDDay(dDay: Int)
11-
12-
@Modifying
13-
@Query("UPDATE DailyWeather dw SET dw.dDay = dw.dDay - 1 WHERE dw.dDay > 0")
14-
fun decrementDDayValues()
9+
fun findBySkiResortAndDDay(skiResort: SkiResort, dDay: Int): DailyWeather?
1510
}

src/main/kotlin/nexters/weski/weather/HourlyWeather.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package nexters.weski.weather
33
import jakarta.persistence.*
44
import nexters.weski.common.BaseEntity
55
import nexters.weski.ski_resort.SkiResort
6-
import java.time.LocalDateTime
76

87
@Entity
98
@Table(name = "hourly_weather")
@@ -12,9 +11,11 @@ data class HourlyWeather(
1211
@GeneratedValue(strategy = GenerationType.IDENTITY)
1312
val id: Long = 0,
1413

15-
val forecastTime: LocalDateTime,
14+
val forecastTime: String,
15+
val priority: Int,
1616
val temperature: Int,
1717
val precipitationChance: Int,
18+
@Column(name = "`condition`")
1819
val condition: String,
1920

2021
@ManyToOne
Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
package nexters.weski.weather
22

3+
import nexters.weski.ski_resort.SkiResort
34
import org.springframework.data.jpa.repository.JpaRepository
4-
import java.time.LocalDateTime
55

66
interface HourlyWeatherRepository : JpaRepository<HourlyWeather, Long> {
7-
fun findAllBySkiResortResortIdAndForecastTimeBetween(
8-
resortId: Long,
9-
startTime: LocalDateTime,
10-
endTime: LocalDateTime
11-
): List<HourlyWeather>
7+
fun deleteBySkiResort(skiResort: SkiResort)
128
}

src/main/kotlin/nexters/weski/weather/WeatherDto.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ data class HourlyWeatherDto(
5555
companion object {
5656
fun fromEntity(entity: HourlyWeather): HourlyWeatherDto {
5757
return HourlyWeatherDto(
58-
time = entity.forecastTime.toLocalTimeString(),
58+
time = entity.forecastTime,
5959
temperature = entity.temperature,
6060
precipitationChance = entity.precipitationChance.toPercentString(),
6161
condition = entity.condition

src/main/kotlin/nexters/weski/weather/WeatherService.kt

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package nexters.weski.weather
22

33
import org.springframework.stereotype.Service
4-
import java.time.LocalDateTime
5-
import java.time.LocalTime
64

75
@Service
86
class WeatherService(
@@ -12,12 +10,7 @@ class WeatherService(
1210
) {
1311
fun getWeatherByResortId(resortId: Long): WeatherDto? {
1412
val currentWeather = currentWeatherRepository.findBySkiResortResortId(resortId) ?: return null
15-
// 오늘, 내일 날짜의 날씨 정보만 가져옴
16-
val startTime = LocalDateTime.now().with(LocalTime.MIN)
17-
val endTime = startTime.plusDays(2).with(LocalTime.MAX)
18-
val hourlyWeather = hourlyWeatherRepository.findAllBySkiResortResortIdAndForecastTimeBetween(
19-
resortId, startTime, endTime
20-
)
13+
val hourlyWeather = hourlyWeatherRepository.findAll()
2114
val dailyWeather = dailyWeatherRepository.findAllBySkiResortResortId(resortId)
2215

2316
return WeatherDto.fromEntities(currentWeather, hourlyWeather, dailyWeather)

0 commit comments

Comments
 (0)