2026-02-21 13:10:54 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use Carbon\Carbon;
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
|
|
|
|
|
|
class WeatherService
|
|
|
|
|
{
|
|
|
|
|
private const BASE_SHORT = 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst';
|
|
|
|
|
|
|
|
|
|
private const BASE_MID_TA = 'https://apis.data.go.kr/1360000/MidFcstInfoService/getMidTa';
|
|
|
|
|
|
|
|
|
|
private const BASE_MID_LAND = 'https://apis.data.go.kr/1360000/MidFcstInfoService/getMidLandFcst';
|
|
|
|
|
|
|
|
|
|
// 서울 격자 좌표
|
|
|
|
|
private const NX = 60;
|
|
|
|
|
|
|
|
|
|
private const NY = 127;
|
|
|
|
|
|
|
|
|
|
// 중기예보 지역 코드 (서울)
|
|
|
|
|
private const MID_TA_REG_ID = '11B10101'; // 서울 기온
|
|
|
|
|
|
|
|
|
|
private const MID_LAND_REG_ID = '11B00000'; // 서울·인천·경기
|
|
|
|
|
|
|
|
|
|
private string $serviceKey;
|
|
|
|
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
{
|
|
|
|
|
$this->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();
|
|
|
|
|
|
|
|
|
|
// 1) 단기예보 (오늘 ~ +2일, 최대 3일)
|
|
|
|
|
$short = $this->fetchShortForecast();
|
|
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
// 2) 중기기온 + 중기날씨 (4일 ~ 10일)
|
2026-02-21 13:10:54 +09:00
|
|
|
$midTa = $this->fetchMidTemperature();
|
|
|
|
|
$midLand = $this->fetchMidLandForecast();
|
|
|
|
|
|
2026-02-21 13:38:02 +09:00
|
|
|
// 오늘부터 7일간 데이터 조합 (단기 + 중기 병합)
|
2026-02-21 13:30:42 +09:00
|
|
|
$forecasts = [];
|
|
|
|
|
for ($i = 0; $i < 7; $i++) {
|
2026-02-21 13:10:54 +09:00
|
|
|
$date = $today->copy()->addDays($i)->format('Ymd');
|
2026-02-21 13:30:42 +09:00
|
|
|
|
2026-02-21 13:38:02 +09:00
|
|
|
$shortData = $short[$date] ?? null;
|
2026-02-21 13:30:42 +09:00
|
|
|
|
|
|
|
|
// 중기예보 데이터
|
2026-02-21 13:38:02 +09:00
|
|
|
$midTmn = $midTa["taMin{$i}"] ?? null;
|
|
|
|
|
$midTmx = $midTa["taMax{$i}"] ?? null;
|
|
|
|
|
$midWf = $midLand["wf{$i}Am"] ?? ($midLand["wf{$i}"] ?? '');
|
2026-02-21 13:40:28 +09:00
|
|
|
$midPop = max($midLand["rnSt{$i}Am"] ?? 0, $midLand["rnSt{$i}Pm"] ?? 0, $midLand["rnSt{$i}"] ?? 0);
|
2026-02-21 13:38:02 +09:00
|
|
|
|
|
|
|
|
if ($shortData) {
|
2026-02-21 13:40:28 +09:00
|
|
|
// 단기예보 우선, 기온/강수확률 없으면 중기로 보충
|
2026-02-21 13:38:02 +09:00
|
|
|
$forecasts[] = [
|
|
|
|
|
'date' => $date,
|
|
|
|
|
'tmn' => $shortData['tmn'] ?? $midTmn,
|
|
|
|
|
'tmx' => $shortData['tmx'] ?? $midTmx,
|
|
|
|
|
'icon' => $shortData['icon'] ?? (! empty($midWf) ? $this->midWeatherToIcon($midWf) : null),
|
|
|
|
|
'weather_text' => $shortData['weather_text'] ?: $midWf,
|
2026-02-21 13:40:28 +09:00
|
|
|
'pop' => $shortData['pop'] ?? $midPop,
|
2026-02-21 13:38:02 +09:00
|
|
|
];
|
|
|
|
|
} else {
|
|
|
|
|
$forecasts[] = [
|
|
|
|
|
'date' => $date,
|
|
|
|
|
'tmn' => $midTmn,
|
|
|
|
|
'tmx' => $midTmx,
|
|
|
|
|
'icon' => ! empty($midWf) ? $this->midWeatherToIcon($midWf) : null,
|
|
|
|
|
'weather_text' => $midWf,
|
2026-02-21 13:40:28 +09:00
|
|
|
'pop' => $midPop,
|
2026-02-21 13:38:02 +09:00
|
|
|
];
|
|
|
|
|
}
|
2026-02-21 13:10:54 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-21 13:10:54 +09:00
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
return $response->json();
|
2026-02-21 13:10:54 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 단기예보 API 호출 — TMN/TMX/SKY/PTY 추출
|
|
|
|
|
*/
|
|
|
|
|
private function fetchShortForecast(): array
|
|
|
|
|
{
|
|
|
|
|
$now = Carbon::now();
|
|
|
|
|
$baseDate = $now->hour < 2 ? $now->copy()->subDay()->format('Ymd') : $now->format('Ymd');
|
|
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
$body = $this->callApi(self::BASE_SHORT, [
|
2026-02-21 13:10:54 +09:00
|
|
|
'pageNo' => 1,
|
|
|
|
|
'numOfRows' => 1000,
|
|
|
|
|
'base_date' => $baseDate,
|
2026-02-21 13:30:42 +09:00
|
|
|
'base_time' => '0200',
|
2026-02-21 13:10:54 +09:00
|
|
|
'nx' => self::NX,
|
|
|
|
|
'ny' => self::NY,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
if (! $body) {
|
2026-02-21 13:10:54 +09:00
|
|
|
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])) {
|
2026-02-21 13:40:28 +09:00
|
|
|
$grouped[$fcstDate] = ['date' => $fcstDate, 'tmn' => null, 'tmx' => null, 'sky' => null, 'pty' => null, 'pop' => 0];
|
2026-02-21 13:10:54 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-02-21 13:40:28 +09:00
|
|
|
'POP' => $grouped[$fcstDate]['pop'] = max($grouped[$fcstDate]['pop'], (int) $value),
|
2026-02-21 13:10:54 +09:00
|
|
|
default => null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$result = [];
|
|
|
|
|
foreach ($grouped as $date => $data) {
|
2026-02-21 13:30:42 +09:00
|
|
|
$icon = $this->shortWeatherToIcon($data['sky'] ?? 1, $data['pty'] ?? 0);
|
2026-02-21 13:10:54 +09:00
|
|
|
$result[$date] = [
|
|
|
|
|
'date' => $date,
|
|
|
|
|
'tmn' => $data['tmn'],
|
|
|
|
|
'tmx' => $data['tmx'],
|
2026-02-21 13:30:42 +09:00
|
|
|
'icon' => $icon,
|
|
|
|
|
'weather_text' => $this->iconToText($icon),
|
2026-02-21 13:40:28 +09:00
|
|
|
'pop' => $data['pop'],
|
2026-02-21 13:10:54 +09:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 중기기온 API 호출
|
|
|
|
|
*/
|
|
|
|
|
private function fetchMidTemperature(): array
|
|
|
|
|
{
|
2026-02-21 13:30:42 +09:00
|
|
|
$body = $this->callApi(self::BASE_MID_TA, [
|
2026-02-21 13:10:54 +09:00
|
|
|
'pageNo' => 1,
|
|
|
|
|
'numOfRows' => 10,
|
|
|
|
|
'regId' => self::MID_TA_REG_ID,
|
2026-02-21 13:30:42 +09:00
|
|
|
'tmFc' => $this->getMidBaseTmFc(),
|
2026-02-21 13:10:54 +09:00
|
|
|
]);
|
|
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
if (! $body) {
|
2026-02-21 13:10:54 +09:00
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
$items = data_get($body, 'response.body.items.item', []);
|
2026-02-21 13:10:54 +09:00
|
|
|
|
|
|
|
|
return $items[0] ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 중기날씨 API 호출
|
|
|
|
|
*/
|
|
|
|
|
private function fetchMidLandForecast(): array
|
|
|
|
|
{
|
2026-02-21 13:30:42 +09:00
|
|
|
$body = $this->callApi(self::BASE_MID_LAND, [
|
2026-02-21 13:10:54 +09:00
|
|
|
'pageNo' => 1,
|
|
|
|
|
'numOfRows' => 10,
|
|
|
|
|
'regId' => self::MID_LAND_REG_ID,
|
2026-02-21 13:30:42 +09:00
|
|
|
'tmFc' => $this->getMidBaseTmFc(),
|
2026-02-21 13:10:54 +09:00
|
|
|
]);
|
|
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
if (! $body) {
|
2026-02-21 13:10:54 +09:00
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 13:30:42 +09:00
|
|
|
$items = data_get($body, 'response.body.items.item', []);
|
2026-02-21 13:10:54 +09:00
|
|
|
|
|
|
|
|
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 → 아이콘 이름
|
|
|
|
|
*/
|
|
|
|
|
private function shortWeatherToIcon(int $sky, int $pty): string
|
|
|
|
|
{
|
|
|
|
|
if ($pty > 0) {
|
|
|
|
|
return match ($pty) {
|
2026-02-21 13:30:42 +09:00
|
|
|
1, 4 => 'rain',
|
|
|
|
|
2 => 'sleet',
|
|
|
|
|
3 => 'snow',
|
|
|
|
|
5 => 'rain',
|
|
|
|
|
6 => 'sleet',
|
|
|
|
|
7 => 'snow',
|
2026-02-21 13:10:54 +09:00
|
|
|
default => 'cloud',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return match ($sky) {
|
2026-02-21 13:30:42 +09:00
|
|
|
1 => 'sun',
|
|
|
|
|
3 => 'cloud-sun',
|
|
|
|
|
4 => 'cloud',
|
2026-02-21 13:10:54 +09:00
|
|
|
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';
|
|
|
|
|
}
|
2026-02-21 13:30:42 +09:00
|
|
|
if (str_contains($wf, '구름많음') || str_contains($wf, '구름')) {
|
2026-02-21 13:10:54 +09:00
|
|
|
return 'cloud-sun';
|
|
|
|
|
}
|
|
|
|
|
if (str_contains($wf, '맑음')) {
|
|
|
|
|
return 'sun';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'cloud';
|
|
|
|
|
}
|
2026-02-21 13:30:42 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 아이콘 → 한글 텍스트
|
|
|
|
|
*/
|
|
|
|
|
private function iconToText(string $icon): string
|
|
|
|
|
{
|
|
|
|
|
return match ($icon) {
|
|
|
|
|
'sun' => '맑음',
|
|
|
|
|
'cloud-sun' => '구름많음',
|
|
|
|
|
'cloud' => '흐림',
|
|
|
|
|
'rain' => '비',
|
|
|
|
|
'snow' => '눈',
|
|
|
|
|
'sleet' => '비/눈',
|
|
|
|
|
default => '',
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-02-21 13:10:54 +09:00
|
|
|
}
|