매 요청 균일하게 발생하던 부팅 오버헤드를 4개 축에서 제거한다. - 설치 완료 상태에서 매 요청 반복되던 information_schema 조회(알림·본인인증 테이블 hasTable)를 installer_completed 가드로 스킵. 미설치 환경은 기존 hasTable 폴백 유지. - 코어+모듈+플러그인 정적 훅 매핑을 bootstrap/cache/hooks.php 에 사전 계산해 매 요청 디렉토리 스캔·리플렉션·클래스 로딩을 제거(route:cache 동형). 등록↔발화 계약·매핑 바이트 동일. 확장/코어 변경 시 자동 재생성, 캐시 부재·손상은 스캔 폴백. - 확장 소스(Modules\*/Plugins\*)를 autoload-extensions.php 의 classmap 에 편입해 findFile 파일시스템 스캔을 제거(느린 FS·cold OPcache 환경 직격). 클래스 로딩은 여전히 lazy, PSR-4 폴백 유지. - config 캐시를 변경 지점(설정 저장/코어·확장 업데이트/APP_KEY 재생성/설치 완료)에서 clear 후 즉시 재생성하도록 ConfigCacheHelper 로 일원화. clear 만 하고 방치돼 캐시가 영구 비활성으로 남던 성능 손실 제거. HookCacheManager::read 요청당 1회 로드(memo)로 중복 파싱 제거. 부팅 매핑 fingerprint 스캔↔캐시 완전 동일(229 action + 35 filter). 신규/수정 8개 스위트 54 pass. 순수 내부 부팅 인프라 — 확장 공개 표면·훅 계약 불변.
379 lines
14 KiB
PHP
379 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
|
use App\Extension\HookManager;
|
|
use App\Support\ConfigCacheHelper;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* MaxMind GeoLite2 데이터베이스 관리 서비스.
|
|
*
|
|
* 다운로드/압축 해제/원자적 교체/메타데이터 기록을 담당합니다.
|
|
* GeoIpService(IP → 타임존 조회)와 책임이 분리되어 있습니다.
|
|
*/
|
|
class GeoIpDatabaseService
|
|
{
|
|
public function __construct(
|
|
private ConfigRepositoryInterface $configRepository
|
|
) {}
|
|
|
|
/**
|
|
* MaxMind GeoLite2 DB를 다운로드하여 원자적으로 교체합니다.
|
|
*
|
|
* @param bool $force 파일이 이미 존재해도 재다운로드
|
|
* @return array{success: bool, status: string, message?: string, data?: array<string, mixed>}
|
|
* status: 'updated' | 'skipped' | 'missing_license_key' | 'download_failed' |
|
|
* 'unauthorized' | 'extract_failed' | 'file_not_found' | 'replace_failed' |
|
|
* 'connection_failed' | 'empty_response'
|
|
*/
|
|
public function updateDatabase(bool $force = false): array
|
|
{
|
|
$licenseKey = (string) config('geoip.license_key', '');
|
|
|
|
if ($licenseKey === '') {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'missing_license_key',
|
|
'message' => 'MaxMind 라이선스 키가 설정되지 않았습니다.',
|
|
];
|
|
}
|
|
|
|
$finalPath = (string) config('geoip.database_path');
|
|
|
|
// 최근 업데이트 건너뛰기 (force 없이, 24시간 이내)
|
|
if (! $force && File::exists($finalPath)) {
|
|
$ageSeconds = time() - File::lastModified($finalPath);
|
|
if ($ageSeconds < 86400) {
|
|
return [
|
|
'success' => true,
|
|
'status' => 'skipped',
|
|
'message' => sprintf('최근 %d시간 전에 업데이트되었습니다.', (int) floor($ageSeconds / 3600)),
|
|
'data' => $this->getDatabaseStatus(),
|
|
];
|
|
}
|
|
}
|
|
|
|
$editionId = (string) config('geoip.download.edition_id', 'GeoLite2-City');
|
|
|
|
// Before 훅: 확장이 업데이트 시작 시점에 부가 작업을 수행할 수 있음
|
|
HookManager::doAction('core.geoip.database.before_update', [
|
|
'edition_id' => $editionId,
|
|
'force' => $force,
|
|
]);
|
|
|
|
// Filter 훅: 확장이 다운로드 옵션을 변형할 수 있음 (edition_id, base_url 등 오버라이드 가능)
|
|
$downloadContext = HookManager::applyFilters('core.geoip.database.filter_download_context', [
|
|
'edition_id' => $editionId,
|
|
'base_url' => (string) config('geoip.download.base_url', 'https://download.maxmind.com/app/geoip_download'),
|
|
'timeout' => (int) config('geoip.download.timeout', 120),
|
|
'retry_attempts' => (int) config('geoip.download.retry_attempts', 3),
|
|
'retry_delay_ms' => (int) config('geoip.download.retry_delay_ms', 10000),
|
|
'user_agent' => (string) config('geoip.download.user_agent', 'g7-core/1.0'),
|
|
]);
|
|
|
|
$url = $this->buildDownloadUrl($licenseKey, (string) $downloadContext['edition_id'], (string) $downloadContext['base_url']);
|
|
|
|
$geoipDir = dirname($finalPath);
|
|
$tmpDir = $geoipDir.DIRECTORY_SEPARATOR.'.tmp';
|
|
$archivePath = $tmpDir.DIRECTORY_SEPARATOR.$editionId.'.tar.gz';
|
|
$extractPath = $tmpDir.DIRECTORY_SEPARATOR.'extract';
|
|
|
|
try {
|
|
$this->prepareDirectories($geoipDir, $tmpDir, $extractPath);
|
|
|
|
$startedAt = microtime(true);
|
|
|
|
// 1. 다운로드
|
|
$downloadResult = $this->downloadArchive($url, $archivePath, $downloadContext);
|
|
if (! $downloadResult['success']) {
|
|
HookManager::doAction('core.geoip.database.after_update_failed', $downloadResult);
|
|
|
|
return $downloadResult;
|
|
}
|
|
|
|
// 2. 압축 해제
|
|
$extractResult = $this->extractArchive($archivePath, $extractPath);
|
|
if (! $extractResult['success']) {
|
|
HookManager::doAction('core.geoip.database.after_update_failed', $extractResult);
|
|
|
|
return $extractResult;
|
|
}
|
|
|
|
// 3. mmdb 파일 탐색
|
|
$extractedMmdb = $this->findMmdbFile($extractPath, $editionId);
|
|
if ($extractedMmdb === null) {
|
|
$result = [
|
|
'success' => false,
|
|
'status' => 'file_not_found',
|
|
'message' => '압축 파일 내부에서 mmdb 파일을 찾을 수 없습니다.',
|
|
];
|
|
HookManager::doAction('core.geoip.database.after_update_failed', $result);
|
|
|
|
return $result;
|
|
}
|
|
|
|
// 4. 원자적 교체
|
|
if (! @rename($extractedMmdb, $finalPath)) {
|
|
$result = [
|
|
'success' => false,
|
|
'status' => 'replace_failed',
|
|
'message' => 'mmdb 파일을 최종 경로로 이동할 수 없습니다.',
|
|
];
|
|
HookManager::doAction('core.geoip.database.after_update_failed', $result);
|
|
|
|
return $result;
|
|
}
|
|
|
|
// 5. 메타데이터 갱신
|
|
$this->updateLastUpdatedAt();
|
|
|
|
$elapsed = round(microtime(true) - $startedAt, 2);
|
|
$finalSize = File::size($finalPath);
|
|
|
|
Log::info('GeoIP DB updated successfully', [
|
|
'path' => $finalPath,
|
|
'size_bytes' => $finalSize,
|
|
'elapsed_seconds' => $elapsed,
|
|
]);
|
|
|
|
$result = [
|
|
'success' => true,
|
|
'status' => 'updated',
|
|
'message' => 'GeoIP DB 업데이트가 완료되었습니다.',
|
|
'data' => [
|
|
'database_path' => $finalPath,
|
|
'file_size_bytes' => $finalSize,
|
|
'elapsed_seconds' => $elapsed,
|
|
'last_updated_at' => date('c', File::lastModified($finalPath)),
|
|
],
|
|
];
|
|
|
|
// After 훅: 확장이 업데이트 완료 시점에 부가 작업 수행 (캐시 워밍, 알림 등)
|
|
HookManager::doAction('core.geoip.database.after_update', $result);
|
|
|
|
return $result;
|
|
} finally {
|
|
// 임시 파일 정리 (성공/실패 무관)
|
|
if (File::isDirectory($tmpDir)) {
|
|
File::deleteDirectory($tmpDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 DB 파일의 상태 정보를 반환합니다.
|
|
*
|
|
* @return array{exists: bool, database_path: string, file_size_bytes: int, last_updated_at: ?string}
|
|
*/
|
|
public function getDatabaseStatus(): array
|
|
{
|
|
$finalPath = (string) config('geoip.database_path');
|
|
$exists = File::exists($finalPath);
|
|
|
|
return [
|
|
'exists' => $exists,
|
|
'database_path' => $finalPath,
|
|
'file_size_bytes' => $exists ? File::size($finalPath) : 0,
|
|
'last_updated_at' => $exists ? date('c', File::lastModified($finalPath)) : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 라이선스 키 설정 여부를 확인합니다.
|
|
*
|
|
* @return bool 설정 여부
|
|
*/
|
|
public function isLicenseKeyConfigured(): bool
|
|
{
|
|
return (string) config('geoip.license_key', '') !== '';
|
|
}
|
|
|
|
/**
|
|
* 다운로드 URL(dry-run용 마스킹 포함)을 반환합니다.
|
|
*
|
|
* @param bool $masked true면 라이선스 키를 마스킹
|
|
* @return string 다운로드 URL
|
|
*/
|
|
public function buildDownloadUrlForDisplay(bool $masked = true): string
|
|
{
|
|
$licenseKey = (string) config('geoip.license_key', '');
|
|
$editionId = (string) config('geoip.download.edition_id', 'GeoLite2-City');
|
|
$url = $this->buildDownloadUrl($licenseKey, $editionId);
|
|
|
|
if ($masked && $licenseKey !== '') {
|
|
return str_replace($licenseKey, $this->maskKey($licenseKey), $url);
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
/**
|
|
* 라이선스 키의 앞 4자만 남기고 나머지를 마스킹합니다.
|
|
*
|
|
* @param string $key 원본 키
|
|
* @return string 마스킹된 키
|
|
*/
|
|
public function maskKey(string $key): string
|
|
{
|
|
$length = strlen($key);
|
|
if ($length <= 4) {
|
|
return str_repeat('*', $length);
|
|
}
|
|
|
|
return substr($key, 0, 4).str_repeat('*', $length - 4);
|
|
}
|
|
|
|
/**
|
|
* 다운로드 URL을 조합합니다.
|
|
*
|
|
* @param string $licenseKey 라이선스 키
|
|
* @param string $editionId 에디션 ID (예: GeoLite2-City)
|
|
* @param string|null $baseUrl 베이스 URL (null이면 config 기본값)
|
|
* @return string 전체 URL
|
|
*/
|
|
private function buildDownloadUrl(string $licenseKey, string $editionId, ?string $baseUrl = null): string
|
|
{
|
|
$baseUrl ??= (string) config('geoip.download.base_url', 'https://download.maxmind.com/app/geoip_download');
|
|
|
|
return sprintf('%s?edition_id=%s&license_key=%s&suffix=tar.gz', $baseUrl, $editionId, $licenseKey);
|
|
}
|
|
|
|
/**
|
|
* 작업 디렉토리를 준비합니다.
|
|
*
|
|
* @param string $geoipDir GeoIP 최종 디렉토리
|
|
* @param string $tmpDir 임시 디렉토리
|
|
* @param string $extractPath 추출 경로
|
|
*/
|
|
private function prepareDirectories(string $geoipDir, string $tmpDir, string $extractPath): void
|
|
{
|
|
if (! File::isDirectory($geoipDir)) {
|
|
File::makeDirectory($geoipDir, 0755, true);
|
|
}
|
|
if (File::isDirectory($tmpDir)) {
|
|
File::deleteDirectory($tmpDir);
|
|
}
|
|
File::makeDirectory($tmpDir, 0755, true);
|
|
File::makeDirectory($extractPath, 0755, true);
|
|
}
|
|
|
|
/**
|
|
* 아카이브를 다운로드합니다.
|
|
*
|
|
* @param string $url 다운로드 URL
|
|
* @param string $archivePath 저장 경로
|
|
* @param array<string, mixed> $context 다운로드 옵션 (filter 훅으로 변형된 컨텍스트)
|
|
* @return array{success: bool, status: string, message?: string} 결과 배열
|
|
*/
|
|
private function downloadArchive(string $url, string $archivePath, array $context): array
|
|
{
|
|
$timeout = (int) ($context['timeout'] ?? 120);
|
|
$retryAttempts = (int) ($context['retry_attempts'] ?? 3);
|
|
$retryDelayMs = (int) ($context['retry_delay_ms'] ?? 10000);
|
|
$userAgent = (string) ($context['user_agent'] ?? 'g7-core/1.0');
|
|
|
|
try {
|
|
// retry의 기본 $throw=true는 4xx/5xx 시 RequestException을 던집니다.
|
|
// 우리는 응답을 받아 직접 상태 코드를 검사해야 하므로 throw=false를 지정합니다.
|
|
$response = Http::withHeaders(['User-Agent' => $userAgent])
|
|
->timeout($timeout)
|
|
->retry($retryAttempts, $retryDelayMs, null, false)
|
|
->sink($archivePath)
|
|
->get($url);
|
|
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'connection_failed',
|
|
'message' => '다운로드 연결 실패: '.$e->getMessage(),
|
|
];
|
|
}
|
|
|
|
if ($response->status() === 401) {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'unauthorized',
|
|
'message' => '라이선스 키가 유효하지 않습니다. (HTTP 401)',
|
|
];
|
|
}
|
|
|
|
if (! $response->successful()) {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'download_failed',
|
|
'message' => sprintf('다운로드 실패: HTTP %d', $response->status()),
|
|
];
|
|
}
|
|
|
|
if (! File::exists($archivePath) || File::size($archivePath) === 0) {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'empty_response',
|
|
'message' => '다운로드된 파일이 비어 있습니다.',
|
|
];
|
|
}
|
|
|
|
return ['success' => true, 'status' => 'ok'];
|
|
}
|
|
|
|
/**
|
|
* tar.gz 아카이브를 추출합니다.
|
|
*
|
|
* @param string $archivePath 아카이브 경로
|
|
* @param string $extractPath 추출 대상 경로
|
|
* @return array{success: bool, status: string, message?: string} 결과 배열
|
|
*/
|
|
private function extractArchive(string $archivePath, string $extractPath): array
|
|
{
|
|
try {
|
|
$phar = new \PharData($archivePath);
|
|
$phar->extractTo($extractPath, null, true);
|
|
} catch (\Throwable $e) {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'extract_failed',
|
|
'message' => '압축 해제 실패: '.$e->getMessage(),
|
|
];
|
|
}
|
|
|
|
return ['success' => true, 'status' => 'ok'];
|
|
}
|
|
|
|
/**
|
|
* 추출된 디렉토리에서 mmdb 파일을 찾습니다.
|
|
*
|
|
* MaxMind 아카이브는 `GeoLite2-City_YYYYMMDD/GeoLite2-City.mmdb` 형식입니다.
|
|
*
|
|
* @param string $extractPath 추출 경로
|
|
* @param string $editionId 에디션 ID
|
|
* @return string|null mmdb 파일 경로 또는 null
|
|
*/
|
|
private function findMmdbFile(string $extractPath, string $editionId): ?string
|
|
{
|
|
$pattern = $extractPath.DIRECTORY_SEPARATOR.$editionId.'_*'.DIRECTORY_SEPARATOR.$editionId.'.mmdb';
|
|
$found = glob($pattern);
|
|
|
|
return ! empty($found) ? $found[0] : null;
|
|
}
|
|
|
|
/**
|
|
* settings의 cache 카테고리에 geoip_last_updated_at을 기록합니다.
|
|
*/
|
|
private function updateLastUpdatedAt(): void
|
|
{
|
|
try {
|
|
$current = $this->configRepository->getCategory('geoip');
|
|
$current['last_updated_at'] = now()->toIso8601String();
|
|
$this->configRepository->saveCategory('geoip', $current);
|
|
|
|
// config 캐시 재로드 강제 + 즉시 재생성 (clear 만 하면 캐시 비활성 잔존).
|
|
ConfigCacheHelper::rebuild();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('GeoIP last_updated_at 갱신 실패', ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
}
|