diff --git a/app/Http/Controllers/DashboardWeatherController.php b/app/Http/Controllers/DashboardWeatherController.php new file mode 100644 index 00000000..363e7aa1 --- /dev/null +++ b/app/Http/Controllers/DashboardWeatherController.php @@ -0,0 +1,15 @@ +getWeeklyForecast(); + + return view('dashboard.partials.weather', compact('forecasts')); + } +} diff --git a/app/Services/WeatherService.php b/app/Services/WeatherService.php new file mode 100644 index 00000000..e1433196 --- /dev/null +++ b/app/Services/WeatherService.php @@ -0,0 +1,280 @@ +serviceKey = config('services.kma.service_key', ''); + } + + /** + * 7일 예보 데이터 반환 (3시간 캐시) + */ + public function getWeeklyForecast(): array + { + if (empty($this->serviceKey)) { + return []; + } + + return Cache::remember('weather_forecast_7days', 10800, function () { + try { + return $this->buildForecast(); + } catch (\Throwable $e) { + Log::warning('WeatherService: 예보 조합 실패', ['error' => $e->getMessage()]); + + return []; + } + }); + } + + private function buildForecast(): array + { + $today = Carbon::today(); + $forecasts = []; + + // 1) 단기예보 (오늘 ~ +2일, 최대 3일) + $short = $this->fetchShortForecast(); + foreach ($short as $date => $data) { + $forecasts[$date] = $data; + } + + // 2) 중기기온 + 중기날씨 (3일 ~ 7일) + $midTa = $this->fetchMidTemperature(); + $midLand = $this->fetchMidLandForecast(); + + for ($i = 3; $i <= 7; $i++) { + $date = $today->copy()->addDays($i)->format('Ymd'); + $forecasts[$date] = [ + 'date' => $date, + 'tmn' => $midTa["taMin{$i}"] ?? null, + 'tmx' => $midTa["taMax{$i}"] ?? null, + 'icon' => $this->midWeatherToIcon($midLand["wf{$i}Am"] ?? ($midLand["wf{$i}"] ?? '')), + ]; + } + + // 날짜순 정렬 후 7일만 + ksort($forecasts); + + return array_slice(array_values($forecasts), 0, 7); + } + + /** + * 단기예보 API 호출 — TMN/TMX/SKY/PTY 추출 + */ + private function fetchShortForecast(): array + { + $now = Carbon::now(); + // base_date: 오늘, base_time: 0200 (02시 발표 → TMN/TMX 포함) + $baseDate = $now->hour < 2 ? $now->copy()->subDay()->format('Ymd') : $now->format('Ymd'); + $baseTime = '0200'; + + $response = Http::timeout(10)->get(self::BASE_SHORT, [ + 'serviceKey' => $this->serviceKey, + 'pageNo' => 1, + 'numOfRows' => 1000, + 'dataType' => 'JSON', + 'base_date' => $baseDate, + 'base_time' => $baseTime, + 'nx' => self::NX, + 'ny' => self::NY, + ]); + + if (! $response->successful()) { + Log::warning('WeatherService: 단기예보 API 실패', ['status' => $response->status()]); + + return []; + } + + $body = $response->json(); + $items = data_get($body, 'response.body.items.item', []); + + if (empty($items)) { + return []; + } + + // 날짜별 그룹 + $grouped = []; + foreach ($items as $item) { + $fcstDate = $item['fcstDate']; + $category = $item['category']; + $value = $item['fcstValue']; + + if (! isset($grouped[$fcstDate])) { + $grouped[$fcstDate] = ['date' => $fcstDate, 'tmn' => null, 'tmx' => null, 'sky' => null, 'pty' => null]; + } + + match ($category) { + 'TMN' => $grouped[$fcstDate]['tmn'] = (int) $value, + 'TMX' => $grouped[$fcstDate]['tmx'] = (int) $value, + 'SKY' => $grouped[$fcstDate]['sky'] ??= (int) $value, + 'PTY' => $grouped[$fcstDate]['pty'] ??= (int) $value, + default => null, + }; + } + + // 아이콘 매핑 + $result = []; + foreach ($grouped as $date => $data) { + $result[$date] = [ + 'date' => $date, + 'tmn' => $data['tmn'], + 'tmx' => $data['tmx'], + 'icon' => $this->shortWeatherToIcon($data['sky'] ?? 1, $data['pty'] ?? 0), + ]; + } + + return $result; + } + + /** + * 중기기온 API 호출 + */ + private function fetchMidTemperature(): array + { + $tmFc = $this->getMidBaseTmFc(); + + $response = Http::timeout(10)->get(self::BASE_MID_TA, [ + 'serviceKey' => $this->serviceKey, + 'pageNo' => 1, + 'numOfRows' => 10, + 'dataType' => 'JSON', + 'regId' => self::MID_TA_REG_ID, + 'tmFc' => $tmFc, + ]); + + if (! $response->successful()) { + return []; + } + + $items = data_get($response->json(), 'response.body.items.item', []); + + return $items[0] ?? []; + } + + /** + * 중기날씨 API 호출 + */ + private function fetchMidLandForecast(): array + { + $tmFc = $this->getMidBaseTmFc(); + + $response = Http::timeout(10)->get(self::BASE_MID_LAND, [ + 'serviceKey' => $this->serviceKey, + 'pageNo' => 1, + 'numOfRows' => 10, + 'dataType' => 'JSON', + 'regId' => self::MID_LAND_REG_ID, + 'tmFc' => $tmFc, + ]); + + if (! $response->successful()) { + return []; + } + + $items = data_get($response->json(), 'response.body.items.item', []); + + return $items[0] ?? []; + } + + /** + * 중기예보 발표시각 (06시/18시 기준) + */ + private function getMidBaseTmFc(): string + { + $now = Carbon::now(); + + if ($now->hour >= 18) { + return $now->format('Ymd').'1800'; + } elseif ($now->hour >= 6) { + return $now->format('Ymd').'0600'; + } + + return $now->copy()->subDay()->format('Ymd').'1800'; + } + + /** + * 단기예보 SKY + PTY → 아이콘 이름 + * PTY 우선: 비/눈/진눈깨비가 있으면 해당 아이콘 + */ + private function shortWeatherToIcon(int $sky, int $pty): string + { + // PTY (강수형태) 우선 + if ($pty > 0) { + return match ($pty) { + 1, 4 => 'rain', // 비, 소나기 + 2 => 'sleet', // 비/눈 + 3 => 'snow', // 눈 + 5 => 'rain', // 빗방울 + 6 => 'sleet', // 빗방울/눈날림 + 7 => 'snow', // 눈날림 + default => 'cloud', + }; + } + + // SKY (하늘상태) + return match ($sky) { + 1 => 'sun', // 맑음 + 3 => 'cloud-sun', // 구름많음 + 4 => 'cloud', // 흐림 + default => 'sun', + }; + } + + /** + * 중기예보 한글 날씨 문자열 → 아이콘 이름 + */ + private function midWeatherToIcon(string $wf): string + { + if (empty($wf)) { + return 'cloud'; + } + + $wf = mb_strtolower($wf); + + if (str_contains($wf, '눈') && str_contains($wf, '비')) { + return 'sleet'; + } + if (str_contains($wf, '눈')) { + return 'snow'; + } + if (str_contains($wf, '비') || str_contains($wf, '소나기')) { + return 'rain'; + } + if (str_contains($wf, '흐림')) { + return 'cloud'; + } + if (str_contains($wf, '구름')) { + return 'cloud-sun'; + } + if (str_contains($wf, '맑음')) { + return 'sun'; + } + + return 'cloud'; + } +} diff --git a/config/services.php b/config/services.php index aed0f8e0..f11a24fe 100644 --- a/config/services.php +++ b/config/services.php @@ -101,4 +101,15 @@ 'app_url' => env('DEV_APP_URL', 'https://dev.sam.kr'), ], + /* + |-------------------------------------------------------------------------- + | 기상청 공공데이터포털 API + |-------------------------------------------------------------------------- + | 단기예보 + 중기예보 조합으로 7일 날씨 제공 + | - KMA_SERVICE_KEY: data.go.kr 인코딩 서비스키 + */ + 'kma' => [ + 'service_key' => env('KMA_SERVICE_KEY', ''), + ], + ]; diff --git a/resources/views/dashboard/index.blade.php b/resources/views/dashboard/index.blade.php index a1e8cbcf..d5c7c09b 100644 --- a/resources/views/dashboard/index.blade.php +++ b/resources/views/dashboard/index.blade.php @@ -40,6 +40,36 @@ + +
+
+

+ + + + 주간 날씨 +

+ 서울 +
+
+ {{-- 스켈레톤 로딩 --}} +
+ @for($i = 0; $i < 7; $i++) +
+
+
+
+
+
+
+ @endfor +
+
+
+
'sun']) --}} +@php $icon = $icon ?? 'cloud'; @endphp + +@if($icon === 'sun') +{{-- 맑음 --}} + + + + + + + + + + + + + + +@elseif($icon === 'cloud-sun') +{{-- 구름많음 --}} + + + + + + + + + + + + +@elseif($icon === 'cloud') +{{-- 흐림 --}} + + + + +@elseif($icon === 'rain') +{{-- 비 --}} + + + + + + + + + +@elseif($icon === 'snow') +{{-- 눈 --}} + + + + + + + + + +@elseif($icon === 'sleet') +{{-- 진눈깨비 --}} + + + + + + + + + + +@else +{{-- 기본: 흐림 --}} + + + +@endif diff --git a/resources/views/dashboard/partials/weather.blade.php b/resources/views/dashboard/partials/weather.blade.php new file mode 100644 index 00000000..533c93ef --- /dev/null +++ b/resources/views/dashboard/partials/weather.blade.php @@ -0,0 +1,55 @@ +{{-- 주간 날씨 위젯 (HTMX 파티셜) --}} +@if(empty($forecasts)) +
+ + + + 날씨 정보를 불러올 수 없습니다 +
+@else + @php + $today = now()->format('Ymd'); + $dayNames = ['일', '월', '화', '수', '목', '금', '토']; + @endphp +
+ @foreach($forecasts as $fc) + @php + $dt = \Carbon\Carbon::createFromFormat('Ymd', $fc['date']); + $isToday = ($fc['date'] === $today); + $dayName = $dayNames[$dt->dayOfWeek]; + $isSunday = $dt->dayOfWeek === 0; + $isSaturday = $dt->dayOfWeek === 6; + @endphp +
+ {{-- 요일 --}} +
+ {{ $isToday ? '오늘' : $dayName }} +
+ {{-- 날짜 --}} +
+ {{ $dt->format('m.d') }} +
+ {{-- 날씨 아이콘 --}} +
+ @include('dashboard.partials.weather-icon', ['icon' => $fc['icon'] ?? 'cloud']) +
+ {{-- 최고/최저 기온 --}} +
+ @if($fc['tmx'] !== null) +
{{ $fc['tmx'] }}°
+ @else +
-
+ @endif + @if($fc['tmn'] !== null) +
{{ $fc['tmn'] }}°
+ @else +
-
+ @endif +
+
+ @endforeach +
+@endif diff --git a/routes/web.php b/routes/web.php index f4ed2a33..0360650a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -656,6 +656,9 @@ return view('dashboard.index'); })->name('dashboard'); + // 대시보드 날씨 + Route::get('/dashboard/weather', [\App\Http\Controllers\DashboardWeatherController::class, 'weather'])->name('dashboard.weather'); + // 대시보드 달력 Route::get('/dashboard/calendar', [\App\Http\Controllers\DashboardCalendarController::class, 'calendar'])->name('dashboard.calendar'); Route::post('/dashboard/schedules', [\App\Http\Controllers\DashboardCalendarController::class, 'store'])->name('dashboard.schedules.store');