254 lines
10 KiB
PHP
254 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class AnalyticsService
|
|
{
|
|
private string $propertyId;
|
|
private string $credentialsPath;
|
|
private string $cachePath;
|
|
private int $cacheTtl;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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);
|
|
}
|
|
}
|