serviceKey = config('services.kma.service_key', ''); } /** * 7일 예보 데이터 반환 (완전한 데이터 3시간, 불완전 10분 캐시) */ public function getWeeklyForecast(): array { if (empty($this->serviceKey)) { return []; } $cached = Cache::get('weather_forecast_7days'); if ($cached !== null) { return $cached; } try { $forecasts = $this->buildForecast(); } catch (\Throwable $e) { Log::warning('WeatherService: 예보 조합 실패', ['error' => $e->getMessage()]); return []; } // 데이터 없는 날이 있으면 10분만 캐시 (API 재시도 유도) $hasIncomplete = collect($forecasts)->contains( fn ($fc) => $fc['tmn'] === null && $fc['tmx'] === null && $fc['icon'] === null ); Cache::put('weather_forecast_7days', $forecasts, $hasIncomplete ? 600 : 10800); return $forecasts; } private function buildForecast(): array { $today = Carbon::today(); // 1) 단기예보 (오늘 ~ +2일, 최대 3일) $short = $this->fetchShortForecast(); // 2) 중기기온 + 중기날씨 (4일 ~ 10일) $midTa = $this->fetchMidTemperature(); $midLand = $this->fetchMidLandForecast(); // 오늘부터 7일간 데이터 조합 (단기 + 중기 병합) $forecasts = []; for ($i = 0; $i < 7; $i++) { $date = $today->copy()->addDays($i)->format('Ymd'); $shortData = $short[$date] ?? null; // 중기예보 데이터 $midTmn = $midTa["taMin{$i}"] ?? null; $midTmx = $midTa["taMax{$i}"] ?? null; $midWf = $midLand["wf{$i}Am"] ?? ($midLand["wf{$i}"] ?? ''); $midPop = max($midLand["rnSt{$i}Am"] ?? 0, $midLand["rnSt{$i}Pm"] ?? 0, $midLand["rnSt{$i}"] ?? 0); $midIcon = ! empty($midWf) ? $this->midWeatherToIcon($midWf) : null; // 단기예보에 TMN/TMX가 있으면 완전한 데이터로 판단 $shortComplete = $shortData && ($shortData['tmn'] !== null || $shortData['tmx'] !== null); if ($shortComplete) { // 단기예보 우선, 기온만 없으면 중기로 보충 $forecasts[] = [ 'date' => $date, 'tmn' => $shortData['tmn'] ?? $midTmn, 'tmx' => $shortData['tmx'] ?? $midTmx, 'icon' => $shortData['icon'] ?? $midIcon, 'weather_text' => $shortData['weather_text'] ?: $midWf, 'pop' => $shortData['pop'] ?? $midPop, ]; } else { // 중기예보 우선 (단기예보가 없거나 TMN/TMX 미포함) $forecasts[] = [ 'date' => $date, 'tmn' => $midTmn ?? ($shortData['tmn'] ?? null), 'tmx' => $midTmx ?? ($shortData['tmx'] ?? null), 'icon' => $midIcon ?? ($shortData['icon'] ?? null), 'weather_text' => ! empty($midWf) ? $midWf : ($shortData['weather_text'] ?? ''), 'pop' => $midPop > 0 ? $midPop : ($shortData['pop'] ?? 0), ]; } } return $forecasts; } /** * 기상청 API 호출 헬퍼 — serviceKey 이중 인코딩 방지 */ private function callApi(string $baseUrl, array $params): ?array { $params['serviceKey'] = $this->serviceKey; $params['dataType'] = 'JSON'; $queryString = http_build_query($params, '', '&', PHP_QUERY_RFC3986); $response = Http::timeout(10) ->withOptions(['query' => $queryString]) ->get($baseUrl); if (! $response->successful()) { Log::warning('WeatherService API 실패', [ 'url' => $baseUrl, 'status' => $response->status(), ]); return null; } return $response->json(); } /** * 단기예보 API 호출 — TMN/TMX/SKY/PTY 추출 */ private function fetchShortForecast(): array { $now = Carbon::now(); $baseDate = $now->hour < 2 ? $now->copy()->subDay()->format('Ymd') : $now->format('Ymd'); $body = $this->callApi(self::BASE_SHORT, [ 'pageNo' => 1, 'numOfRows' => 1000, 'base_date' => $baseDate, 'base_time' => '0200', 'nx' => self::NX, 'ny' => self::NY, ]); if (! $body) { return []; } $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, 'pop' => 0]; } 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, 'POP' => $grouped[$fcstDate]['pop'] = max($grouped[$fcstDate]['pop'], (int) $value), default => null, }; } $result = []; foreach ($grouped as $date => $data) { $icon = $this->shortWeatherToIcon($data['sky'] ?? 1, $data['pty'] ?? 0); $result[$date] = [ 'date' => $date, 'tmn' => $data['tmn'], 'tmx' => $data['tmx'], 'icon' => $icon, 'weather_text' => $this->iconToText($icon), 'pop' => $data['pop'], ]; } return $result; } /** * 중기기온 API 호출 (최신 발표 우선, 이전 발표로 빈 필드 보충) * * 기상청 중기예보 API 특성: * - 06시 발표: D+4 ~ D+10 제공 (D+3 없음) * - 18시 발표: D+5 ~ D+10 제공 (D+3, D+4 없음) * → 18시에는 최신 데이터가 D+5부터만 있으므로, 06시 데이터로 D+4를 보충 */ private function fetchMidTemperature(): array { return $this->fetchMidMerged(self::BASE_MID_TA, self::MID_TA_REG_ID); } /** * 중기날씨 API 호출 (최신 발표 우선, 이전 발표로 빈 필드 보충) */ private function fetchMidLandForecast(): array { return $this->fetchMidMerged(self::BASE_MID_LAND, self::MID_LAND_REG_ID); } /** * 중기예보 데이터를 여러 발표시각에서 병합 (최신 우선) */ private function fetchMidMerged(string $baseUrl, string $regId): array { $merged = []; foreach ($this->getMidBaseTmFcCandidates() as $tmFc) { $body = $this->callApi($baseUrl, [ 'pageNo' => 1, 'numOfRows' => 10, 'regId' => $regId, 'tmFc' => $tmFc, ]); $items = $body ? data_get($body, 'response.body.items.item', []) : []; if (empty($items)) { continue; } // 최신 발표 데이터 우선, 이전 발표로 빈 필드만 보충 foreach ($items[0] as $key => $value) { if ($value !== null && $value !== '' && ! isset($merged[$key])) { $merged[$key] = $value; } } } return $merged; } /** * 중기예보 발표시각 후보 목록 (우선순위순, fallback 포함) */ private function getMidBaseTmFcCandidates(): array { $now = Carbon::now(); $today = $now->format('Ymd'); $yesterday = $now->copy()->subDay()->format('Ymd'); if ($now->hour >= 18) { // 18시 발표 → 06시 발표 → 전일 18시 발표 return [$today.'1800', $today.'0600', $yesterday.'1800']; } elseif ($now->hour >= 6) { // 06시 발표 → 전일 18시 발표 return [$today.'0600', $yesterday.'1800']; } // 자정~06시: 전일 18시 발표 → 전일 06시 발표 return [$yesterday.'1800', $yesterday.'0600']; } /** * 단기예보 SKY + PTY → 아이콘 이름 */ private function shortWeatherToIcon(int $sky, int $pty): string { if ($pty > 0) { return match ($pty) { 1, 4 => 'rain', 2 => 'sleet', 3 => 'snow', 5 => 'rain', 6 => 'sleet', 7 => 'snow', default => 'cloud', }; } return match ($sky) { 1 => 'sun', 3 => 'cloud-sun', 4 => 'cloud', default => 'sun', }; } /** * 중기예보 한글 날씨 문자열 → 아이콘 이름 */ private function midWeatherToIcon(string $wf): string { if (empty($wf)) { return 'cloud'; } 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, '구름많음') || str_contains($wf, '구름')) { return 'cloud-sun'; } if (str_contains($wf, '맑음')) { return 'sun'; } return 'cloud'; } /** * 아이콘 → 한글 텍스트 */ private function iconToText(string $icon): string { return match ($icon) { 'sun' => '맑음', 'cloud-sun' => '구름많음', 'cloud' => '흐림', 'rain' => '비', 'snow' => '눈', 'sleet' => '비/눈', default => '', }; } }