Files
gtsit/app/Services/AnalyticsService.php
2026-08-18 10:53:58 +09:00

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);
}
}