propertyId = (string) config('app.analytics.property_id', ''); $this->credentialsPath = (string) config('app.analytics.credentials_path', ''); $this->cachePath = (string) config('app.analytics.cache_path', ''); $this->cacheTtl = max(60, (int) config('app.analytics.cache_ttl', 900)); } public function dashboard(): array { $cache = $this->readCache(); if ($cache !== null && time() - (int) ($cache['cached_at'] ?? 0) < $this->cacheTtl) { return $cache + ['available' => true, 'stale' => false]; } try { $credentials = $this->credentials(); $token = $this->accessToken($credentials); $data = [ 'available' => true, 'stale' => false, 'cached_at' => time(), 'realtime' => $this->realtime($token), 'summary' => $this->summary($token), 'all_time_users' => $this->allTimeUsers($token), 'daily' => $this->daily($token), 'pages' => $this->pages($token), 'channels' => $this->channels($token), ]; $this->writeCache($data); return $data; } catch (Throwable) { if ($cache !== null) { $cache['available'] = true; $cache['stale'] = true; return $cache; } return [ 'available' => false, 'stale' => false, 'message' => '방문 통계를 불러오지 못했습니다. 잠시 후 다시 확인해 주세요.', ]; } } private function credentials(): array { if ($this->propertyId === '' || !is_file($this->credentialsPath) || !is_readable($this->credentialsPath)) { throw new RuntimeException('Analytics configuration is unavailable.'); } $credentials = json_decode((string) file_get_contents($this->credentialsPath), true); if (!is_array($credentials) || empty($credentials['client_email']) || empty($credentials['private_key'])) { throw new RuntimeException('Analytics credentials are invalid.'); } return $credentials; } private function accessToken(array $credentials): string { $now = time(); $header = $this->base64Url(json_encode(['alg' => 'RS256', 'typ' => 'JWT'], JSON_THROW_ON_ERROR)); $claims = $this->base64Url(json_encode([ 'iss' => $credentials['client_email'], 'scope' => 'https://www.googleapis.com/auth/analytics.readonly', 'aud' => 'https://oauth2.googleapis.com/token', 'iat' => $now, 'exp' => $now + 3600, ], JSON_THROW_ON_ERROR)); $unsigned = $header . '.' . $claims; $signature = ''; if (!openssl_sign($unsigned, $signature, (string) $credentials['private_key'], OPENSSL_ALGO_SHA256)) { throw new RuntimeException('Analytics token signing failed.'); } $jwt = $unsigned . '.' . $this->base64Url($signature); $response = $this->request('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ], true); $token = (string) ($response['access_token'] ?? ''); if ($token === '') { throw new RuntimeException('Analytics token was not issued.'); } return $token; } private function realtime(string $token): int { $report = $this->report($token, 'runRealtimeReport', [ 'metrics' => [['name' => 'activeUsers']], ]); return (int) ($report['rows'][0]['metricValues'][0]['value'] ?? 0); } private function summary(string $token): array { $report = $this->report($token, 'runReport', [ 'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']], 'metrics' => array_map(static fn(string $name): array => ['name' => $name], [ 'activeUsers', 'newUsers', 'screenPageViews', 'sessions', ]), ]); $values = $report['rows'][0]['metricValues'] ?? []; return [ 'active_users' => (int) ($values[0]['value'] ?? 0), 'new_users' => (int) ($values[1]['value'] ?? 0), 'page_views' => (int) ($values[2]['value'] ?? 0), 'sessions' => (int) ($values[3]['value'] ?? 0), ]; } private function allTimeUsers(string $token): int { $report = $this->report($token, 'runReport', [ 'dateRanges' => [['startDate' => '2026-08-12', 'endDate' => 'today']], 'metrics' => [['name' => 'totalUsers']], ]); return (int) ($report['rows'][0]['metricValues'][0]['value'] ?? 0); } private function daily(string $token): array { $report = $this->report($token, 'runReport', [ 'dateRanges' => [['startDate' => '13daysAgo', 'endDate' => 'today']], 'dimensions' => [['name' => 'date']], 'metrics' => [['name' => 'activeUsers'], ['name' => 'screenPageViews']], 'orderBys' => [['dimension' => ['dimensionName' => 'date']]], ]); return array_map(static function (array $row): array { $date = (string) ($row['dimensionValues'][0]['value'] ?? ''); return [ 'date' => strlen($date) === 8 ? substr($date, 4, 2) . '.' . substr($date, 6, 2) : $date, 'users' => (int) ($row['metricValues'][0]['value'] ?? 0), 'views' => (int) ($row['metricValues'][1]['value'] ?? 0), ]; }, $report['rows'] ?? []); } private function pages(string $token): array { $report = $this->report($token, 'runReport', [ 'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']], 'dimensions' => [['name' => 'pageTitle'], ['name' => 'pagePath']], 'metrics' => [['name' => 'screenPageViews']], 'orderBys' => [['metric' => ['metricName' => 'screenPageViews'], 'desc' => true]], 'limit' => 5, ]); return array_map(static fn(array $row): array => [ 'title' => (string) ($row['dimensionValues'][0]['value'] ?? '(제목 없음)'), 'path' => (string) ($row['dimensionValues'][1]['value'] ?? '/'), 'views' => (int) ($row['metricValues'][0]['value'] ?? 0), ], $report['rows'] ?? []); } private function channels(string $token): array { $report = $this->report($token, 'runReport', [ 'dateRanges' => [['startDate' => '30daysAgo', 'endDate' => 'today']], 'dimensions' => [['name' => 'sessionDefaultChannelGroup']], 'metrics' => [['name' => 'sessions'], ['name' => 'activeUsers']], 'orderBys' => [['metric' => ['metricName' => 'sessions'], 'desc' => true]], 'limit' => 5, ]); return array_map(static fn(array $row): array => [ 'channel' => (string) ($row['dimensionValues'][0]['value'] ?? 'Unassigned'), 'sessions' => (int) ($row['metricValues'][0]['value'] ?? 0), 'users' => (int) ($row['metricValues'][1]['value'] ?? 0), ], $report['rows'] ?? []); } private function report(string $token, string $method, array $body): array { return $this->request( 'https://analyticsdata.googleapis.com/v1beta/properties/' . rawurlencode($this->propertyId) . ':' . $method, $body, false, $token ); } private function request(string $url, array $body, bool $form = false, string $token = ''): array { $handle = curl_init($url); if ($handle === false) { throw new RuntimeException('HTTP client initialization failed.'); } $headers = ['Accept: application/json']; $payload = $form ? http_build_query($body) : json_encode($body, JSON_THROW_ON_ERROR); $headers[] = $form ? 'Content-Type: application/x-www-form-urlencoded' : 'Content-Type: application/json'; if ($token !== '') { $headers[] = 'Authorization: Bearer ' . $token; } curl_setopt_array($handle, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 12, ]); $raw = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); $error = curl_error($handle); curl_close($handle); if (!is_string($raw) || $status < 200 || $status >= 300) { throw new RuntimeException('Analytics API request failed: ' . ($error !== '' ? $error : (string) $status)); } $decoded = json_decode($raw, true); if (!is_array($decoded)) { throw new RuntimeException('Analytics API response is invalid.'); } return $decoded; } private function base64Url(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private function readCache(): ?array { if ($this->cachePath === '' || !is_file($this->cachePath) || !is_readable($this->cachePath)) { return null; } $cache = json_decode((string) file_get_contents($this->cachePath), true); return is_array($cache) ? $cache : null; } private function writeCache(array $data): void { $directory = dirname($this->cachePath); if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { return; } file_put_contents($this->cachePath, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), LOCK_EX); @chmod($this->cachePath, 0600); } }