diff --git a/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php b/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php new file mode 100644 index 00000000..c8b7cda9 --- /dev/null +++ b/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php @@ -0,0 +1,43 @@ + $attributes 기록할 속성 (url, locale, layout_name, module_identifier, type, response_time_ms) + */ + public function record(array $attributes): void; + + /** + * 전체 캐시 통계를 집계합니다. + * + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} 집계 결과 + */ + public function aggregate(?Carbon $since = null): array; + + /** + * 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다. + * + * @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier) + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array 그룹별 집계 결과 + */ + public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array; + + /** + * 기준 시점보다 오래된 통계 레코드를 삭제합니다. + * + * @param Carbon $cutoff 기준 시점 + * @return int 삭제된 레코드 수 + */ + public function deleteOlderThan(Carbon $cutoff): int; +} diff --git a/app/Http/Controllers/Api/Public/SitemapController.php b/app/Http/Controllers/Api/Public/SitemapController.php index 9a5d6e88..4c55e6d9 100644 --- a/app/Http/Controllers/Api/Public/SitemapController.php +++ b/app/Http/Controllers/Api/Public/SitemapController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public; use App\Contracts\Extension\CacheInterface; use App\Http\Controllers\Controller; +use App\Seo\SeoCacheSettings; use App\Seo\SitemapGenerator; use Illuminate\Http\Response; @@ -22,6 +23,7 @@ class SitemapController extends Controller * * @param SitemapGenerator $generator Sitemap 생성기 * @param CacheInterface $cache 코어 캐시 드라이버 + * @return Response Sitemap XML 응답 */ public function index(SitemapGenerator $generator, CacheInterface $cache): Response { @@ -34,8 +36,8 @@ class SitemapController extends Controller if (! $xml) { $xml = $generator->generate(); - $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400)); - $cache->put('seo.sitemap', $xml, $ttl); + // 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19) + $cache->put('seo.sitemap', $xml, SeoCacheSettings::sitemapCacheTtl()); } return response($xml, 200, [ diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index 00cbac47..d827d885 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -32,6 +32,7 @@ use App\Contracts\Repositories\PluginRepositoryInterface; use App\Contracts\Repositories\RoleRepositoryInterface; use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface; use App\Contracts\Repositories\ScheduleRepositoryInterface; +use App\Contracts\Repositories\SeoCacheStatRepositoryInterface; use App\Contracts\Repositories\SystemConfigRepositoryInterface; use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface; use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface; @@ -79,6 +80,7 @@ use App\Repositories\PluginRepository; use App\Repositories\RoleRepository; use App\Repositories\ScheduleHistoryRepository; use App\Repositories\ScheduleRepository; +use App\Repositories\SeoCacheStatRepository; use App\Repositories\SystemConfigRepository; use App\Repositories\TemplateCustomTranslationRepository; use App\Repositories\TemplateLayoutAttachmentRepository; @@ -216,6 +218,9 @@ class CoreServiceProvider extends ServiceProvider $this->app->bind(NotificationRepositoryInterface::class, NotificationRepository::class); $this->app->bind(NotificationTemplateRepositoryInterface::class, NotificationTemplateRepository::class); + // SEO Repository 바인딩 + $this->app->bind(SeoCacheStatRepositoryInterface::class, SeoCacheStatRepository::class); + // IdentityVerification Repository 바인딩 $this->app->bind(IdentityVerificationLogRepositoryInterface::class, IdentityVerificationLogRepository::class); $this->app->bind(IdentityPolicyRepositoryInterface::class, IdentityPolicyRepository::class); diff --git a/app/Repositories/SeoCacheStatRepository.php b/app/Repositories/SeoCacheStatRepository.php new file mode 100644 index 00000000..a6bb968e --- /dev/null +++ b/app/Repositories/SeoCacheStatRepository.php @@ -0,0 +1,135 @@ + + */ + private const GROUPABLE_COLUMNS = ['layout_name', 'module_identifier']; + + /** + * 캐시 통계 레코드를 기록합니다. + * + * @param array $attributes 기록할 속성 + */ + public function record(array $attributes): void + { + SeoCacheStat::create($attributes); + } + + /** + * 전체 캐시 통계를 집계합니다. + * + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} 집계 결과 + */ + public function aggregate(?Carbon $since = null): array + { + $row = $this->baseQuery($since) + ->select($this->aggregateSelects()) + ->first(); + + return [ + 'total' => (int) ($row->total ?? 0), + 'hits' => (int) ($row->hits ?? 0), + 'misses' => (int) ($row->misses ?? 0), + 'avg_response_time_ms' => $row?->avg_response_time_ms !== null + ? (float) $row->avg_response_time_ms + : null, + ]; + } + + /** + * 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다. + * + * @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier) + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array 그룹별 집계 결과 + * + * @throws \InvalidArgumentException 허용되지 않은 그룹 컬럼인 경우 + */ + public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array + { + if (! in_array($groupBy, self::GROUPABLE_COLUMNS, true)) { + throw new \InvalidArgumentException( + __('exceptions.seo.sitemap_stat_group_column_unsupported', ['column' => $groupBy]) + ); + } + + return $this->baseQuery($since) + ->select(array_merge([$groupBy], $this->aggregateSelects())) + ->groupBy($groupBy) + ->get() + ->map(fn ($row): array => [ + 'group' => $row->{$groupBy}, + 'total' => (int) $row->total, + 'hits' => (int) $row->hits, + 'misses' => (int) $row->misses, + 'avg_response_time_ms' => $row->avg_response_time_ms !== null + ? (float) $row->avg_response_time_ms + : null, + ]) + ->all(); + } + + /** + * 기준 시점보다 오래된 통계 레코드를 삭제합니다. + * + * @param Carbon $cutoff 기준 시점 + * @return int 삭제된 레코드 수 + */ + public function deleteOlderThan(Carbon $cutoff): int + { + return SeoCacheStat::where('created_at', '<', $cutoff)->delete(); + } + + /** + * 기간 필터가 적용된 기본 쿼리를 반환합니다. + * + * @param Carbon|null $since 집계 시작 시점 + * @return Builder 쿼리 빌더 + */ + private function baseQuery(?Carbon $since): Builder + { + $query = SeoCacheStat::query(); + + if ($since) { + $query->since($since); + } + + return $query; + } + + /** + * 집계 select 절을 반환합니다. + * + * hit/miss 는 단일 순회로 집계하기 위해 조건부 합계를 사용합니다. + * + * @return array select 표현식 배열 + */ + private function aggregateSelects(): array + { + return [ + DB::raw('COUNT(*) as total'), + DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"), + DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"), + DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms"), + ]; + } +} diff --git a/app/Seo/SeoCacheManager.php b/app/Seo/SeoCacheManager.php index d64f803c..0df68d37 100644 --- a/app/Seo/SeoCacheManager.php +++ b/app/Seo/SeoCacheManager.php @@ -133,19 +133,19 @@ class SeoCacheManager implements SeoCacheManagerInterface } /** - * 캐시 활성화 여부 확인 + * 캐시 활성화 여부 확인 — 고급 탭 값을 쓰되 SEO 탭에 별도 지정이 있으면 그것이 우선 (D19). */ private function isEnabled(): bool { - return (bool) g7_core_settings('cache.seo_enabled', g7_core_settings('seo.cache_enabled', true)); + return SeoCacheSettings::pageCacheEnabled(); } /** - * 캐시 TTL (초) — g7_core_settings('cache.seo_ttl') 우선. + * 캐시 TTL (초) — 고급 탭 값을 쓰되 SEO 탭에 별도 지정이 있으면 그것이 우선 (D19). */ private function getCacheTtl(): int { - return (int) g7_core_settings('cache.seo_ttl', g7_core_settings('seo.cache_ttl', 7200)); + return SeoCacheSettings::pageCacheTtl(); } /** diff --git a/app/Seo/SeoCacheSettings.php b/app/Seo/SeoCacheSettings.php new file mode 100644 index 00000000..065dc2e4 --- /dev/null +++ b/app/Seo/SeoCacheSettings.php @@ -0,0 +1,64 @@ + $url, - 'locale' => $locale, - 'layout_name' => $layoutName, - 'module_identifier' => $moduleIdentifier, - 'type' => 'hit', - ]); - } catch (\Exception $e) { - Log::warning('[SEO] 캐시 히트 기록 실패', [ - 'url' => $url, - 'error' => $e->getMessage(), - ]); - } + $this->record([ + 'url' => $url, + 'locale' => $locale, + 'layout_name' => $layoutName, + 'module_identifier' => $moduleIdentifier, + 'type' => 'hit', + ], '[SEO] 캐시 히트 기록 실패'); } /** * 캐시 미스를 기록합니다. * - * @param string $url 요청 URL - * @param string $locale 로케일 - * @param string|null $layoutName 레이아웃명 - * @param string|null $moduleIdentifier 모듈 식별자 - * @param int|null $responseTimeMs 렌더링 소요 시간 (ms) - * @return void + * @param string $url 요청 URL + * @param string $locale 로케일 + * @param string|null $layoutName 레이아웃명 + * @param string|null $moduleIdentifier 모듈 식별자 + * @param int|null $responseTimeMs 렌더링 소요 시간 (ms) */ public function recordMiss( string $url, @@ -62,142 +62,72 @@ class SeoCacheStatsService ?string $moduleIdentifier = null, ?int $responseTimeMs = null ): void { - try { - SeoCacheStat::create([ - 'url' => $url, - 'locale' => $locale, - 'layout_name' => $layoutName, - 'module_identifier' => $moduleIdentifier, - 'type' => 'miss', - 'response_time_ms' => $responseTimeMs, - ]); - } catch (\Exception $e) { - Log::warning('[SEO] 캐시 미스 기록 실패', [ - 'url' => $url, - 'error' => $e->getMessage(), - ]); - } + $this->record([ + 'url' => $url, + 'locale' => $locale, + 'layout_name' => $layoutName, + 'module_identifier' => $moduleIdentifier, + 'type' => 'miss', + 'response_time_ms' => $responseTimeMs, + ], '[SEO] 캐시 미스 기록 실패'); } /** * 캐시 통계를 조회합니다. * - * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) - * @return array{total_entries: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null} + * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) + * @return array{total_entries: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null} 캐시 통계 */ public function getStats(?Carbon $since = null): array { - $query = SeoCacheStat::query(); - - if ($since) { - $query->since($since); - } - - $total = $query->count(); - $hits = (clone $query)->hits()->count(); - $misses = (clone $query)->misses()->count(); - $avgResponseTime = (clone $query)->misses() - ->whereNotNull('response_time_ms') - ->avg('response_time_ms'); + $stats = $this->statRepository->aggregate($since); return [ - 'total_entries' => $total, - 'hits' => $hits, - 'misses' => $misses, - 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0, - 'avg_response_time_ms' => $avgResponseTime !== null ? round((float) $avgResponseTime, 2) : null, + 'total_entries' => $stats['total'], + 'hits' => $stats['hits'], + 'misses' => $stats['misses'], + 'hit_rate' => $this->hitRate($stats['total'], $stats['hits']), + 'avg_response_time_ms' => $this->roundOrNull($stats['avg_response_time_ms']), ]; } /** * 레이아웃별 캐시 통계를 조회합니다. * - * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) - * @return array + * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) + * @return array 레이아웃별 캐시 통계 */ public function getStatsByLayout(?Carbon $since = null): array { - $query = SeoCacheStat::query() - ->select( - 'layout_name', - DB::raw('COUNT(*) as total'), - DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"), - DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"), - DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms") - ) - ->groupBy('layout_name'); - - if ($since) { - $query->where('created_at', '>=', $since); - } - - return $query->get()->map(function ($row) { - $total = (int) $row->total; - $hits = (int) $row->hits; - - return [ - 'layout_name' => $row->layout_name, - 'total' => $total, - 'hits' => $hits, - 'misses' => (int) $row->misses, - 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0, - 'avg_response_time_ms' => $row->avg_response_time_ms !== null - ? round((float) $row->avg_response_time_ms, 2) - : null, - ]; - })->toArray(); + return array_map( + fn (array $row): array => ['layout_name' => $row['group']] + $this->formatGroupRow($row), + $this->statRepository->aggregateGrouped('layout_name', $since), + ); } /** * 모듈별 캐시 통계를 조회합니다. * - * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) - * @return array + * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) + * @return array 모듈별 캐시 통계 */ public function getStatsByModule(?Carbon $since = null): array { - $query = SeoCacheStat::query() - ->select( - 'module_identifier', - DB::raw('COUNT(*) as total'), - DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"), - DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"), - DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms") - ) - ->groupBy('module_identifier'); - - if ($since) { - $query->where('created_at', '>=', $since); - } - - return $query->get()->map(function ($row) { - $total = (int) $row->total; - $hits = (int) $row->hits; - - return [ - 'module_identifier' => $row->module_identifier, - 'total' => $total, - 'hits' => $hits, - 'misses' => (int) $row->misses, - 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0, - 'avg_response_time_ms' => $row->avg_response_time_ms !== null - ? round((float) $row->avg_response_time_ms, 2) - : null, - ]; - })->toArray(); + return array_map( + fn (array $row): array => ['module_identifier' => $row['group']] + $this->formatGroupRow($row), + $this->statRepository->aggregateGrouped('module_identifier', $since), + ); } /** * 오래된 통계 레코드를 삭제합니다. * - * @param int $daysToKeep 보존 기간 (일, 기본값: 30) + * @param int $daysToKeep 보존 기간 (일, 기본값: 30) * @return int 삭제된 레코드 수 */ public function cleanup(int $daysToKeep = 30): int { - $cutoff = Carbon::now()->subDays($daysToKeep); - - $deleted = SeoCacheStat::where('created_at', '<', $cutoff)->delete(); + $deleted = $this->statRepository->deleteOlderThan(Carbon::now()->subDays($daysToKeep)); Log::info('[SEO] 캐시 통계 정리 완료', [ 'deleted' => $deleted, @@ -206,4 +136,62 @@ class SeoCacheStatsService return $deleted; } + + /** + * 통계 레코드를 기록하고, 실패해도 요청 흐름을 막지 않습니다. + * + * @param array $attributes 기록할 속성 + * @param string $failureMessage 실패 시 로그 메시지 + */ + private function record(array $attributes, string $failureMessage): void + { + try { + $this->statRepository->record($attributes); + } catch (\Exception $e) { + Log::warning($failureMessage, [ + 'url' => $attributes['url'] ?? null, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 그룹 집계 행을 응답 형식으로 변환합니다. + * + * @param array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} $row 집계 행 + * @return array{total: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null} 변환된 행 + */ + private function formatGroupRow(array $row): array + { + return [ + 'total' => $row['total'], + 'hits' => $row['hits'], + 'misses' => $row['misses'], + 'hit_rate' => $this->hitRate($row['total'], $row['hits']), + 'avg_response_time_ms' => $this->roundOrNull($row['avg_response_time_ms']), + ]; + } + + /** + * 히트율(%)을 계산합니다. + * + * @param int $total 전체 건수 + * @param int $hits 히트 건수 + * @return float 히트율 (소수점 2자리) + */ + private function hitRate(int $total, int $hits): float + { + return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0; + } + + /** + * 값이 있으면 소수점 2자리로 반올림합니다. + * + * @param float|null $value 원본 값 + * @return float|null 반올림된 값 (null 이면 null) + */ + private function roundOrNull(?float $value): ?float + { + return $value !== null ? round($value, 2) : null; + } } diff --git a/app/Seo/SeoServiceProvider.php b/app/Seo/SeoServiceProvider.php index bad62a84..bbc76844 100644 --- a/app/Seo/SeoServiceProvider.php +++ b/app/Seo/SeoServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Seo; +use App\Contracts\Extension\StorageInterface; +use App\Extension\Storage\CoreStorageDriver; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\Contracts\SeoRendererInterface; use Illuminate\Support\ServiceProvider; @@ -28,6 +30,15 @@ class SeoServiceProvider extends ServiceProvider $this->app->singleton(SeoMetaResolver::class); $this->app->singleton(SitemapGenerator::class); $this->app->singleton(SeoConfigMerger::class); + $this->app->singleton(SitemapFileStore::class); + + // Sitemap 파일 입출력은 비공개 디스크(local = storage/app/private, serve=false)를 사용합니다. + // public/storage 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다. + foreach ([SitemapManager::class, SitemapFileStore::class] as $sitemapConsumer) { + $this->app->when($sitemapConsumer) + ->needs(StorageInterface::class) + ->give(fn () => new CoreStorageDriver('local')); + } } /** diff --git a/app/Seo/SitemapFileStore.php b/app/Seo/SitemapFileStore.php new file mode 100644 index 00000000..f34d8a78 --- /dev/null +++ b/app/Seo/SitemapFileStore.php @@ -0,0 +1,207 @@ + 값이며, web 라우트 `/sitemap-{n}.xml` 과 대응합니다. + * + * @param int $n 자식 번호 (1부터) + * @param bool $gzip gzip 압축 여부 + * @return string 자식 sitemap 절대 URL + */ + public static function childUrl(int $n, bool $gzip = false): string + { + return url('/'.self::childFilename($n, $gzip)); + } + + /** + * 커밋된 sitemap 세트가 존재하는지 확인합니다. + * + * manifest.json 존재 = 커밋 완료 신호입니다. + * + * @return bool 서빙 가능한 세트 존재 여부 + */ + public function exists(): bool + { + return $this->storage->exists(self::CATEGORY, self::LIVE_DIR.'/'.self::MANIFEST_FILE); + } + + /** + * manifest 메타데이터를 반환합니다. + * + * @return array{generated_at?: string, url_count?: int, child_count?: int, size_bytes?: int, gzip?: bool, children?: array}|null + * manifest 배열 (없거나 손상 시 null) + */ + public function getManifest(): ?array + { + $raw = $this->storage->get(self::CATEGORY, self::LIVE_DIR.'/'.self::MANIFEST_FILE); + if ($raw === null || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + Log::warning('[SEO] Sitemap manifest 손상', ['path' => self::LIVE_DIR.'/'.self::MANIFEST_FILE]); + + return null; + } + + return $decoded; + } + + /** + * sitemapindex 를 스트리밍 응답으로 반환합니다. + * + * @return StreamedResponse|null 인덱스 스트림 (세트가 없으면 null) + */ + public function indexResponse(): ?StreamedResponse + { + if (! $this->exists()) { + return null; + } + + return $this->storage->response( + self::CATEGORY, + self::LIVE_DIR.'/'.self::INDEX_FILE, + self::INDEX_FILE, + ['Content-Type' => 'application/xml'], + ); + } + + /** + * 자식 sitemap 을 스트리밍 응답으로 반환합니다. + * + * 실제 저장된 파일명은 manifest 에서 조회하므로, 생성 이후 gzip 설정이 + * 바뀌어도 커밋된 세트를 그대로 서빙합니다. + * + * @param int $n 자식 번호 (1부터) + * @return StreamedResponse|null 자식 스트림 (없으면 null) + */ + public function childResponse(int $n): ?StreamedResponse + { + $filename = $this->childFile($n); + if ($filename === null) { + return null; + } + + $headers = ['Content-Type' => 'application/xml']; + if (str_ends_with($filename, '.gz')) { + $headers['Content-Encoding'] = 'gzip'; + } + + return $this->storage->response( + self::CATEGORY, + self::LIVE_DIR.'/'.$filename, + $filename, + $headers, + ); + } + + /** + * 커밋된 sitemap 의 생성 시각을 반환합니다. + * + * @return string|null ISO8601 생성 시각 (세트가 없으면 null) + */ + public function lastGeneratedAt(): ?string + { + $manifest = $this->getManifest(); + + return $manifest['generated_at'] ?? null; + } + + /** + * 자식 번호에 해당하는 실제 파일명을 반환합니다. + * + * @param int $n 자식 번호 (1부터) + * @return string|null 파일명 (존재하지 않으면 null) + */ + private function childFile(int $n): ?string + { + if ($n < 1) { + return null; + } + + $manifest = $this->getManifest(); + $children = $manifest['children'] ?? []; + + $filename = $children[$n - 1]['file'] ?? null; + if (! is_string($filename) || $filename === '') { + return null; + } + + if (! $this->storage->exists(self::CATEGORY, self::LIVE_DIR.'/'.$filename)) { + return null; + } + + return $filename; + } +} diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php index 6c300acf..b843fc1d 100644 --- a/app/Seo/SitemapGenerator.php +++ b/app/Seo/SitemapGenerator.php @@ -40,9 +40,108 @@ class SitemapGenerator $this->contributors[$contributor->getIdentifier()] = $contributor; } + /** + * 수집한 URL을 SitemapWriter로 스트리밍하여 분할 sitemap 파일을 생성합니다. + * + * 기여자를 하나씩 순회하며 URL을 writer 에 한 건씩 넘기므로 + * 전체 URL 배열을 메모리에 병합(array_merge)하지 않습니다. + * + * @param SitemapWriter $writer 분할 기록기 + * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array} + * 커밋된 sitemap 메타데이터 + */ + public function generateToWriter(SitemapWriter $writer): array + { + $writer->open(); + + foreach ($this->collectStaticRoutes() as $entry) { + $writer->addUrl($entry); + } + + foreach ($this->contributors as $contributor) { + try { + foreach ($this->drain($contributor) as $entry) { + $writer->addUrl($this->normalizeEntry($entry)); + } + } catch (\Throwable $e) { + Log::warning('[SEO] Sitemap contributor failed', [ + 'contributor' => $contributor->getIdentifier(), + 'error' => $e->getMessage(), + ]); + } + } + + $writer->close(); + + return $writer->commit(); + } + + /** + * 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다. + * + * @param SitemapContributorInterface $contributor Sitemap 기여자 + * @return iterable> URL 항목 순회자 + */ + private function drain(SitemapContributorInterface $contributor): iterable + { + return $contributor->getUrls(); + } + + /** + * 기여자 URL 항목의 'url' 키를 'loc'(절대 URL)으로 변환합니다. + * + * @param array $entry URL 항목 + * @return array 변환된 URL 항목 + */ + private function normalizeEntry(array $entry): array + { + if (isset($entry['url']) && ! isset($entry['loc'])) { + $entry['loc'] = url($entry['url']); + unset($entry['url']); + } + + return $entry; + } + + /** + * URL 항목 하나를 블록 XML로 렌더링합니다. + * + * XML 이스케이프의 단일 출처는 SitemapXmlRenderer 이며 이 메서드는 위임합니다. + * + * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 + * @return string 블록 문자열 + */ + public function renderUrlBlock(array $entry): string + { + return SitemapXmlRenderer::fromConfig()->urlBlock($entry); + } + + /** + * urlset 여는 태그(XML 선언 포함)를 반환합니다. + * + * @return string urlset 헤더 문자열 + */ + public function buildUrlsetHeader(): string + { + return SitemapXmlRenderer::fromConfig()->urlsetHeader(); + } + + /** + * urlset 닫는 태그를 반환합니다. + * + * @return string urlset 푸터 문자열 + */ + public function buildUrlsetFooter(): string + { + return SitemapXmlRenderer::fromConfig()->urlsetFooter(); + } + /** * 전체 Sitemap URL을 수집하여 XML을 생성합니다. * + * 전체 URL을 메모리에 적재하므로 소규모 사이트 / 하위호환 용도로만 사용합니다. + * 대용량은 generateToWriter() 를 사용합니다. + * * @return string sitemap XML 문자열 */ public function generate(): string @@ -55,16 +154,11 @@ class SitemapGenerator // 2. 기여자들의 동적 URL 수집 foreach ($this->contributors as $contributor) { try { - $contributorUrls = $contributor->getUrls(); - // 기여자 URL의 'url' 키를 'loc'(절대 URL)으로 변환 - foreach ($contributorUrls as &$entry) { - if (isset($entry['url']) && ! isset($entry['loc'])) { - $entry['loc'] = url($entry['url']); - unset($entry['url']); - } - } - unset($entry); + $contributorUrls = array_map( + fn (array $entry): array => $this->normalizeEntry($entry), + $contributor->getUrls(), + ); $urls = array_merge($urls, $contributorUrls); } catch (\Throwable $e) { @@ -170,79 +264,15 @@ class SitemapGenerator */ private function buildXml(array $urls): string { - $defaultLocale = config('app.locale'); - $supportedLocales = config('app.supported_locales', [$defaultLocale]); - $isMultilingual = count($supportedLocales) > 1; + $renderer = SitemapXmlRenderer::fromConfig(); - $xml = ''."\n"; - - if ($isMultilingual) { - $xml .= 'urlsetHeader(); foreach ($urls as $entry) { - $baseLoc = $entry['loc']; - - if ($isMultilingual) { - // 각 로케일별 항목 생성 - foreach ($supportedLocales as $locale) { - $localeLoc = $locale === $defaultLocale - ? $baseLoc - : $baseLoc.'?locale='.$locale; - - $xml .= ' '."\n"; - $xml .= ' '.htmlspecialchars($localeLoc, ENT_XML1, 'UTF-8').''."\n"; - - if (! empty($entry['lastmod'])) { - $xml .= ' '.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').''."\n"; - } - - if (! empty($entry['changefreq'])) { - $xml .= ' '.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').''."\n"; - } - - if (isset($entry['priority'])) { - $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n"; - } - - // 모든 로케일의 hreflang alternate 링크 - foreach ($supportedLocales as $altLocale) { - $altHref = $altLocale === $defaultLocale - ? $baseLoc - : $baseLoc.'?locale='.$altLocale; - $xml .= ' '."\n"; - } - - // x-default = 기본 로케일 URL - $xml .= ' '."\n"; - - $xml .= ' '."\n"; - } - } else { - // 단일 언어: 기존 형식 유지 - $xml .= ' '."\n"; - $xml .= ' '.htmlspecialchars($baseLoc, ENT_XML1, 'UTF-8').''."\n"; - - if (! empty($entry['lastmod'])) { - $xml .= ' '.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').''."\n"; - } - - if (! empty($entry['changefreq'])) { - $xml .= ' '.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').''."\n"; - } - - if (isset($entry['priority'])) { - $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n"; - } - - $xml .= ' '."\n"; - } + $xml .= $renderer->urlBlock($entry); } - $xml .= ''; + $xml .= $renderer->urlsetFooter(); return $xml; } diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index 10a34530..cb82aa48 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -3,6 +3,7 @@ namespace App\Seo; use App\Contracts\Extension\CacheInterface; +use App\Contracts\Extension\StorageInterface; use App\Contracts\Repositories\ConfigRepositoryInterface; use App\Extension\HookManager; use Illuminate\Support\Facades\Log; @@ -11,22 +12,34 @@ use Illuminate\Support\Facades\Log; * Sitemap 재생성 / 상태 조회 서비스. * * 큐 잡(GenerateSitemapJob)과 관리자 수동 트리거(SeoCacheController)에서 공통으로 사용합니다. - * Sitemap XML 을 생성하여 캐시에 저장하고 마지막 업데이트 시각을 - * settings 의 seo 카테고리(sitemap_last_updated_at)에 기록합니다. + * Sitemap 을 SitemapWriter 로 스트리밍 생성하여 비공개 디스크에 분할 파일로 커밋하고, + * 마지막 업데이트 시각을 settings 의 seo 카테고리(sitemap_last_updated_at)에 기록합니다. + * 캐시에는 XML 본문이 아니라 메타데이터만 저장합니다. */ class SitemapManager { + /** + * Sitemap 메타데이터 캐시 키 + */ + public const META_CACHE_KEY = 'seo.sitemap.meta'; + + /** + * 구 버전 Sitemap XML 본문 캐시 키 (디스크 서빙 전환으로 폐기 — 잔여분 정리용) + */ + private const LEGACY_XML_CACHE_KEY = 'seo.sitemap'; + public function __construct( private SitemapGenerator $generator, private CacheInterface $cache, private ConfigRepositoryInterface $configRepository, + private StorageInterface $storage, ) {} /** - * Sitemap 을 즉시 생성하여 캐시에 저장하고 last_updated_at 을 기록합니다. + * Sitemap 을 즉시 생성하여 디스크에 커밋하고 last_updated_at 을 기록합니다. * * @return array{success: bool, status: string, message?: string, data?: array} - * status: 'updated' | 'disabled' | 'failed' + * status: 'updated' | 'disabled' | 'failed' */ public function regenerate(): array { @@ -42,11 +55,16 @@ class SitemapManager HookManager::doAction('core.seo.sitemap.before_regenerate'); try { - $xml = $this->generator->generate(); - $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400)); - $this->cache->put('seo.sitemap', $xml, $ttl); + $meta = $this->generator->generateToWriter($this->makeWriter()); - $lastUpdatedAt = now()->toIso8601String(); + // 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19) + $ttl = SeoCacheSettings::sitemapCacheTtl(); + + // 디스크 서빙 전환 — 캐시에는 메타만 저장하고 구 XML 본문 캐시는 정리합니다. + $this->cache->forget(self::LEGACY_XML_CACHE_KEY); + $this->cache->put(self::META_CACHE_KEY, $meta, $ttl); + + $lastUpdatedAt = $meta['generated_at']; $this->updateLastUpdatedAt($lastUpdatedAt); $result = [ @@ -55,7 +73,9 @@ class SitemapManager 'message' => 'Sitemap 생성이 완료되었습니다.', 'data' => [ 'last_updated_at' => $lastUpdatedAt, - 'size_bytes' => strlen($xml), + 'size_bytes' => $meta['size_bytes'], + 'url_count' => $meta['url_count'], + 'child_count' => $meta['child_count'], 'ttl' => $ttl, ], ]; @@ -94,6 +114,21 @@ class SitemapManager ]; } + /** + * 설정값을 반영한 SitemapWriter 를 생성합니다. + * + * @return SitemapWriter 분할 기록기 + */ + private function makeWriter(): SitemapWriter + { + return new SitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + (int) g7_core_settings('seo.sitemap_urls_per_file', SitemapWriter::HARD_URL_CAP), + (bool) g7_core_settings('seo.sitemap_gzip', false), + ); + } + /** * settings 의 seo 카테고리에 sitemap_last_updated_at 을 기록합니다. * diff --git a/app/Seo/SitemapWriter.php b/app/Seo/SitemapWriter.php new file mode 100644 index 00000000..ed65be9c --- /dev/null +++ b/app/Seo/SitemapWriter.php @@ -0,0 +1,338 @@ + 블록 수 + */ + private int $currentUrlCount = 0; + + /** + * 현재 자식 파일 번호 (1부터) + */ + private int $childIndex = 0; + + /** + * 현재 자식 파일이 열려 있는지 여부 + */ + private bool $childOpen = false; + + /** + * 현재 자식 파일의 최신 lastmod + */ + private ?string $childLastmod = null; + + /** + * 기록된 자식 파일 목록 + * + * @var array + */ + private array $children = []; + + /** + * 전체 블록 수 + */ + private int $totalUrls = 0; + + /** + * 전체 자식 파일 바이트 합계 + */ + private int $totalBytes = 0; + + /** + * 파일당 URL 수 임계 + */ + private readonly int $urlsPerFile; + + /** + * SitemapWriter 생성자 + * + * @param StorageInterface $storage 비공개 디스크 스토리지 드라이버 + * @param SitemapXmlRenderer $renderer XML 조각 렌더러 (SitemapGenerator 와 공유) + * @param int $urlsPerFile 파일당 URL 수 (1 ~ HARD_URL_CAP 로 클램프) + * @param bool $gzip 자식 파일 gzip 압축 여부 + */ + public function __construct( + private readonly StorageInterface $storage, + private readonly SitemapXmlRenderer $renderer, + int $urlsPerFile = self::HARD_URL_CAP, + private readonly bool $gzip = false, + ) { + $this->urlsPerFile = max(1, min($urlsPerFile, self::HARD_URL_CAP)); + } + + /** + * 기록을 시작합니다 (임시 디렉토리 초기화 + 상태 리셋). + */ + public function open(): void + { + $this->purgeTmpDirectory(); + + $this->buffer = ''; + $this->currentUrlCount = 0; + $this->childIndex = 0; + $this->childOpen = false; + $this->childLastmod = null; + $this->children = []; + $this->totalUrls = 0; + $this->totalBytes = 0; + } + + /** + * URL 항목 하나를 기록합니다. + * + * 임계(URL 수 또는 바이트) 도달 시 현재 자식 파일을 flush 합니다. + * + * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 + */ + public function addUrl(array $entry): void + { + $block = $this->renderer->urlBlock($entry); + if ($block === '') { + return; + } + + if (! $this->childOpen) { + $this->openNextChild(); + } + + $this->buffer .= $block; + $this->currentUrlCount += $this->renderer->urlBlockCount(); + $this->totalUrls += $this->renderer->urlBlockCount(); + + $lastmod = $entry['lastmod'] ?? null; + if (is_string($lastmod) && $lastmod !== '' && ($this->childLastmod === null || $lastmod > $this->childLastmod)) { + $this->childLastmod = $lastmod; + } + + // static:: 바인딩 — 하위 클래스가 임계값을 낮춰 바이트 분할 분기를 검증할 수 있게 한다 + // (45MB 를 실제로 채우지 않고 같은 경로를 태우기 위함). + if ($this->currentUrlCount >= $this->urlsPerFile || strlen($this->buffer) >= static::MAX_FILE_BYTES) { + $this->flushChild(); + } + } + + /** + * 기록을 마감합니다 (마지막 자식 flush + sitemapindex 기록). + * + * @return array{child_count: int, url_count: int, size_bytes: int, children: array} 기록 메타데이터 + */ + public function close(): array + { + // 자식이 하나도 없으면(URL 0건) 빈 urlset 자식 1개를 남겨 인덱스를 유효하게 유지합니다. + if ($this->childOpen || $this->children === []) { + if (! $this->childOpen) { + $this->openNextChild(); + } + $this->flushChild(); + } + + $indexXml = $this->renderer->sitemapIndex($this->children); + $this->putOrFail(SitemapFileStore::TMP_DIR.'/'.SitemapFileStore::INDEX_FILE, $indexXml); + + return [ + 'child_count' => count($this->children), + 'url_count' => $this->totalUrls, + 'size_bytes' => $this->totalBytes, + 'children' => $this->children, + ]; + } + + /** + * 임시 디렉토리의 세트를 live 로 커밋합니다. + * + * 순서: (1) 기존 live 파일 목록 확보 → (2) _tmp 파일을 live 로 재배치 → + * (3) manifest.json 기록(커밋 마커) → (4) 새 세트에 없는 stale 파일 삭제 → (5) _tmp 정리. + * + * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array} + * 커밋된 sitemap 메타데이터 + * + * @throws \RuntimeException 파일 기록 실패 시 + */ + public function commit(): array + { + $staleFiles = $this->liveFilenames(); + + $newFiles = [SitemapFileStore::INDEX_FILE]; + foreach ($this->children as $child) { + $newFiles[] = $child['file']; + } + + foreach ($newFiles as $filename) { + $content = $this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR.'/'.$filename); + if ($content === null) { + throw new \RuntimeException(__('exceptions.seo.sitemap_temp_file_unreadable', ['file' => $filename])); + } + + $this->putOrFail(SitemapFileStore::LIVE_DIR.'/'.$filename, $content); + unset($content); + } + + $manifest = [ + 'generated_at' => now()->toIso8601String(), + 'child_count' => count($this->children), + 'url_count' => $this->totalUrls, + 'size_bytes' => $this->totalBytes, + 'gzip' => $this->gzip, + 'urls_per_file' => $this->urlsPerFile, + 'children' => $this->children, + ]; + + // manifest 는 마지막에 기록 — 존재 = 커밋 완료 신호 + $this->putOrFail( + SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE, + json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ); + + $this->deleteStaleFiles($staleFiles, $newFiles); + + $this->purgeTmpDirectory(); + + return $manifest; + } + + /** + * 임시 디렉토리를 정리합니다. + * + * 정리 실패는 치명적이지 않습니다 — 임시 파일은 이름 기준으로 덮어쓰이고, + * 커밋은 children 목록에 있는 파일만 live 로 옮기므로 잔여 파일이 결과에 섞이지 않습니다. + * 따라서 예외를 전파하지 않고 경고만 남겨 sitemap 생성 자체가 중단되지 않도록 합니다. + */ + private function purgeTmpDirectory(): void + { + try { + $this->storage->deleteDirectory(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR); + } catch (\Throwable $e) { + Log::warning('[SEO] Sitemap 임시 디렉토리 정리 실패 (생성은 계속 진행)', [ + 'directory' => SitemapFileStore::TMP_DIR, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 다음 자식 파일을 엽니다 (버퍼에 urlset 헤더 기록). + */ + private function openNextChild(): void + { + $this->childIndex++; + $this->buffer = $this->renderer->urlsetHeader(); + $this->currentUrlCount = 0; + $this->childLastmod = null; + $this->childOpen = true; + } + + /** + * 현재 자식 파일 버퍼를 임시 디렉토리에 기록하고 버퍼를 비웁니다. + * + * @throws \RuntimeException 파일 기록 실패 시 + */ + private function flushChild(): void + { + $this->buffer .= $this->renderer->urlsetFooter(); + + $filename = SitemapFileStore::childFilename($this->childIndex, $this->gzip); + $content = $this->gzip ? gzencode($this->buffer, 6) : $this->buffer; + + if ($content === false) { + throw new \RuntimeException(__('exceptions.seo.sitemap_gzip_failed', ['file' => $filename])); + } + + $this->putOrFail(SitemapFileStore::TMP_DIR.'/'.$filename, $content); + + $this->children[] = [ + 'file' => $filename, + 'loc' => SitemapFileStore::childUrl($this->childIndex, $this->gzip), + 'lastmod' => $this->childLastmod, + 'url_count' => $this->currentUrlCount, + 'size_bytes' => strlen($content), + ]; + $this->totalBytes += strlen($content); + + $this->buffer = ''; + $this->currentUrlCount = 0; + $this->childOpen = false; + } + + /** + * live 디렉토리의 파일명 목록을 반환합니다. + * + * @return array 파일명 배열 (디렉토리 제외) + */ + private function liveFilenames(): array + { + $paths = $this->storage->files(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR); + + return array_map(static fn (string $path): string => basename($path), $paths); + } + + /** + * 새 세트에 포함되지 않은 이전 자식 파일을 삭제합니다. + * + * @param array $staleFiles 커밋 전 live 파일명 목록 + * @param array $newFiles 새 세트 파일명 목록 + */ + private function deleteStaleFiles(array $staleFiles, array $newFiles): void + { + $keep = array_merge($newFiles, [SitemapFileStore::MANIFEST_FILE]); + + foreach ($staleFiles as $filename) { + if (in_array($filename, $keep, true)) { + continue; + } + + $this->storage->delete(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.$filename); + } + } + + /** + * 파일을 기록하고 실패 시 예외를 던집니다. + * + * @param string $path 카테고리 하위 상대 경로 + * @param string $content 파일 내용 + * + * @throws \RuntimeException 기록 실패 시 + */ + private function putOrFail(string $path, string $content): void + { + if (! $this->storage->put(SitemapFileStore::CATEGORY, $path, $content)) { + Log::error('[SEO] Sitemap 파일 기록 실패', ['path' => $path]); + + throw new \RuntimeException(__('exceptions.seo.sitemap_write_failed', ['path' => $path])); + } + } +} diff --git a/app/Seo/SitemapXmlRenderer.php b/app/Seo/SitemapXmlRenderer.php new file mode 100644 index 00000000..b2dcb7a1 --- /dev/null +++ b/app/Seo/SitemapXmlRenderer.php @@ -0,0 +1,249 @@ + 블록, sitemapindex 를 생성합니다. + * Sitemap XML 의 이스케이프 로직을 이 클래스 한 곳에서만 수행하며(단일 출처), + * SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 렌더러를 공유합니다. + * + * 다국어(supported_locales 2개 이상)면 URL 하나당 로케일별 블록을 생성하고 + * 각 블록에 xhtml:link hreflang alternate 를 포함합니다. + */ +final class SitemapXmlRenderer +{ + /** + * 다국어 여부 (지원 로케일 2개 이상) + */ + private readonly bool $multilingual; + + /** + * SitemapXmlRenderer 생성자 + * + * @param array $supportedLocales 지원 로케일 목록 + * @param string $defaultLocale 기본 로케일 + */ + public function __construct( + private readonly array $supportedLocales, + private readonly string $defaultLocale, + ) { + $this->multilingual = count($this->supportedLocales) > 1; + } + + /** + * 애플리케이션 로케일 설정으로 렌더러를 생성합니다. + * + * @return self 설정 기반 렌더러 인스턴스 + */ + public static function fromConfig(): self + { + $defaultLocale = (string) config('app.locale'); + + return new self( + (array) config('app.supported_locales', [$defaultLocale]), + $defaultLocale, + ); + } + + /** + * 다국어 모드 여부를 반환합니다. + * + * @return bool 지원 로케일이 2개 이상이면 true + */ + public function isMultilingual(): bool + { + return $this->multilingual; + } + + /** + * URL 항목 하나가 생성하는 블록 개수를 반환합니다. + * + * 분할 임계(파일당 URL 수) 계산에 사용됩니다. + * + * @return int 다국어면 로케일 수, 단일 언어면 1 + */ + public function urlBlockCount(): int + { + return $this->multilingual ? count($this->supportedLocales) : 1; + } + + /** + * urlset 여는 태그(XML 선언 포함)를 반환합니다. + * + * @return string urlset 헤더 문자열 + */ + public function urlsetHeader(): string + { + $xml = ''."\n"; + + if ($this->multilingual) { + $xml .= ''; + } + + /** + * URL 항목 하나를 블록으로 렌더링합니다. + * + * 다국어면 로케일 수만큼의 블록을 이어붙여 반환합니다. + * + * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 (loc 은 절대 URL) + * @return string 블록 문자열 (loc 이 없으면 빈 문자열) + */ + public function urlBlock(array $entry): string + { + $baseLoc = $entry['loc'] ?? ''; + if ($baseLoc === '') { + return ''; + } + + if (! $this->multilingual) { + return $this->renderSingleUrl($baseLoc, $entry); + } + + $xml = ''; + foreach ($this->supportedLocales as $locale) { + $xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale); + } + + return $xml; + } + + /** + * 자식 sitemap 목록을 sitemapindex XML 로 렌더링합니다. + * + * @param array $children 자식 sitemap 목록 + * @return string sitemapindex XML 문자열 + */ + public function sitemapIndex(array $children): string + { + $xml = ''."\n"; + $xml .= ''."\n"; + + foreach ($children as $child) { + $xml .= ' '."\n"; + $xml .= ' '.$this->escape($child['loc']).''."\n"; + + if (! empty($child['lastmod'])) { + $xml .= ' '.$this->escape($child['lastmod']).''."\n"; + } + + $xml .= ' '."\n"; + } + + $xml .= ''; + + return $xml; + } + + /** + * 단일 언어 블록을 렌더링합니다. + * + * @param string $loc 절대 URL + * @param array $entry URL 항목 + * @return string 블록 문자열 + */ + private function renderSingleUrl(string $loc, array $entry): string + { + $xml = ' '."\n"; + $xml .= ' '.$this->escape($loc).''."\n"; + $xml .= $this->renderMetaFields($entry); + $xml .= ' '."\n"; + + return $xml; + } + + /** + * 특정 로케일의 블록(hreflang alternate 포함)을 렌더링합니다. + * + * @param string $baseLoc 기본 로케일 절대 URL + * @param array $entry URL 항목 + * @param string $locale 대상 로케일 + * @return string 블록 문자열 + */ + private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale): string + { + $xml = ' '."\n"; + $xml .= ' '.$this->escape($this->localizedLoc($baseLoc, $locale)).''."\n"; + $xml .= $this->renderMetaFields($entry); + + // 모든 로케일의 hreflang alternate 링크 + foreach ($this->supportedLocales as $altLocale) { + $altHref = $this->localizedLoc($baseLoc, (string) $altLocale); + $xml .= ' '."\n"; + } + + // x-default = 기본 로케일 URL + $xml .= ' '."\n"; + + $xml .= ' '."\n"; + + return $xml; + } + + /** + * lastmod / changefreq / priority 필드를 렌더링합니다. + * + * @param array $entry URL 항목 + * @return string 메타 필드 문자열 + */ + private function renderMetaFields(array $entry): string + { + $xml = ''; + + if (! empty($entry['lastmod'])) { + $xml .= ' '.$this->escape((string) $entry['lastmod']).''."\n"; + } + + if (! empty($entry['changefreq'])) { + $xml .= ' '.$this->escape((string) $entry['changefreq']).''."\n"; + } + + if (isset($entry['priority'])) { + $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n"; + } + + return $xml; + } + + /** + * 로케일별 URL 을 생성합니다. + * + * @param string $baseLoc 기본 로케일 절대 URL + * @param string $locale 대상 로케일 + * @return string 로케일 URL (기본 로케일이면 원본 그대로) + */ + private function localizedLoc(string $baseLoc, string $locale): string + { + return $locale === $this->defaultLocale + ? $baseLoc + : $baseLoc.'?locale='.$locale; + } + + /** + * XML 특수문자를 이스케이프합니다. + * + * @param string $value 원본 문자열 + * @return string 이스케이프된 문자열 + */ + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_XML1, 'UTF-8'); + } +} diff --git a/config/settings/defaults.json b/config/settings/defaults.json index 67291fe7..9eea0f97 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -65,10 +65,10 @@ "og_image_default_height": 630, "twitter_default_card": "summary_large_image", "twitter_default_site": "", - "cache_enabled": true, - "cache_ttl": 7200, + "cache_enabled": null, + "cache_ttl": null, "sitemap_enabled": true, - "sitemap_cache_ttl": 86400, + "sitemap_cache_ttl": null, "sitemap_schedule": "daily", "sitemap_schedule_time": "02:00", "sitemap_last_updated_at": "", diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md index 1d2185ff..2f002776 100644 --- a/docs/backend/api/seo.md +++ b/docs/backend/api/seo.md @@ -154,7 +154,7 @@ _단건 응답: `data` 객체의 필드._ | --- | --- | --- | --- | | last_updated_at | string | `2026-07-08T03:14:49+00:00` | last updated 일시 | | size_bytes | integer | `25489` | 크기 (바이트) | -| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초) | +| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초). 고급 설정의 `cache.seo_sitemap_ttl` 이 기준값이며, SEO 설정 `seo.sitemap_cache_ttl` 에 값이 지정되어 있으면(null 이 아니면) 그 값이 우선합니다 | **응답 예시** diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 75266ba7..ed44ef3a 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.5] - 2026-07-16 + +### Added + +- 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.4] - 2026-07-16 ### Added diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php index f77cd321..0e6628f8 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php @@ -63,4 +63,10 @@ return [ 'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)', ], 'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)', + 'seo' => [ + 'sitemap_write_failed' => 'Sitemapファイル記録に失敗しました: :path', + 'sitemap_gzip_failed' => 'Sitemap子ファイル圧縮に失敗しました: :file', + 'sitemap_temp_file_unreadable' => 'Sitemap一時ファイルを読み込めません: :file', + 'sitemap_stat_group_column_unsupported' => 'サポートされていないグループ基準列です: :column', + ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json index ec1fafe7..4d25cdab 100644 --- a/lang-packs/_bundled/g7-core-ja/language-pack.json +++ b/lang-packs/_bundled/g7-core-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 core Japanese language pack (bundled)", "ja": "G7 コア 日本語 言語パック(バンドル)" }, - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "scope": "core", "target_identifier": null, diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 45bf9ed3..d800ffac 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-07-17 + +### Changed + +- 환경설정 > SEO 화면의 캐시 항목(SEO 캐시 활성화·캐시 유지 시간, Sitemap 캐시 유지 시간) 안내 문구 일본어 번역을 갱신했습니다 — 비워두면 고급 설정의 값을 따르고 값을 넣으면 그 값이 우선한다는 새 동작을 일본어 로케일에서도 올바르게 안내합니다. + ## [1.0.3] - 2026-07-14 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 293e14e6..a047df35 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1410,9 +1410,9 @@ "seo_cache_settings": "SEO キャッシュ 設定", "seo_cache_settings_desc": "SEO ページキャッシュ関連の設定です。", "cache_enabled": "SEO キャッシュ 有効化", - "cache_enabled_desc": "SEO ページをキャッシュして、応答速度を向上させます。", + "cache_enabled_desc": "SEO ページをキャッシュして、応答速度を向上させます。指定しない場合は詳細設定の値に従います。", "cache_ttl": "キャッシュ 保持時間 (秒)", - "cache_ttl_hint": "最小 60 秒、最大 86400 秒 (24 時間)。デフォルト: 7200 秒 (2 時間)", + "cache_ttl_hint": "空欄にすると詳細設定の SEO キャッシュ時間に従います。値を入力するとこの画面の値が優先されます — 最小 60 秒、最大 86400 秒 (24 時間)", "sitemap_settings": "Sitemap 設定", "sitemap_settings_desc": "sitemap.xml 自動生成関連の設定です。", "sitemap_enabled": "Sitemap 生成 有効化", @@ -1424,7 +1424,7 @@ "sitemap_schedule_time": "生成 時刻", "sitemap_schedule_time_hint": "HH:mm 形式で入力してください。(例: 02:00)", "sitemap_cache_ttl": "Sitemap キャッシュ 保持時間 (秒)", - "sitemap_cache_ttl_hint": "最小 3600 秒 (1 時間)、最大 604800 秒 (7 日)。デフォルト: 86400 秒 (24 時間)", + "sitemap_cache_ttl_hint": "空欄にすると詳細設定の Sitemap キャッシュ時間に従います。値を入力するとこの画面の値が優先されます — 最小 3600 秒 (1 時間)、最大 604800 秒 (7 日)", "sitemap_last_updated_at": "最後の生成", "sitemap_manual_update": "今すぐ生成", "sitemap_updating": "生成中...", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json index 9c1a1431..d759ed88 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-admin_basic", diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index 7e2c0016..a876b138 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -79,4 +79,12 @@ return [ 'vendor_dir_not_found' => 'vendor/ directory does not exist. Run composer install first: :path', 'composer_binary_not_found' => 'Composer binary not found. Set the COMPOSER_BINARY environment variable or add composer to your PATH.', ], + + // SEO / Sitemap 관련 예외 + 'seo' => [ + 'sitemap_write_failed' => 'Failed to write sitemap file: :path', + 'sitemap_gzip_failed' => 'Failed to compress sitemap child file: :file', + 'sitemap_temp_file_unreadable' => 'Cannot read sitemap temporary file: :file', + 'sitemap_stat_group_column_unsupported' => 'Unsupported group-by column: :column', + ], ]; diff --git a/lang/ko/exceptions.php b/lang/ko/exceptions.php index 61bef32e..bb5bee32 100644 --- a/lang/ko/exceptions.php +++ b/lang/ko/exceptions.php @@ -79,4 +79,12 @@ return [ 'vendor_dir_not_found' => 'vendor/ 디렉토리가 존재하지 않습니다. 먼저 composer install을 실행하세요: :path', 'composer_binary_not_found' => 'Composer 바이너리를 찾을 수 없습니다. COMPOSER_BINARY 환경변수를 설정하거나 composer를 PATH에 추가하세요.', ], + + // SEO / Sitemap 관련 예외 + 'seo' => [ + 'sitemap_write_failed' => 'Sitemap 파일 기록에 실패했습니다: :path', + 'sitemap_gzip_failed' => 'Sitemap 자식 파일 압축에 실패했습니다: :file', + 'sitemap_temp_file_unreadable' => 'Sitemap 임시 파일을 읽을 수 없습니다: :file', + 'sitemap_stat_group_column_unsupported' => '지원하지 않는 그룹 기준 컬럼입니다: :column', + ], ]; diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 9369166f..5b09aa83 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-07-16 + +### Added + +- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. + +### Changed + +- 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. + ## [1.0.2] - 2026-07-14 ### Fixed diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json index a59b9a81..02e719ba 100644 --- a/modules/_bundled/sirsoft-board/composer.json +++ b/modules/_bundled/sirsoft-board/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-board", "description": "Board module for Gnuboard7", "type": "library", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index 1476f801..0847a707 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -5,7 +5,7 @@ "ko": "게시판", "en": "Board" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "description": { "ko": "게시판 관리를 위한 모듈", diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json index 1f256d45..8c3338ef 100644 --- a/modules/_bundled/sirsoft-board/package-lock.json +++ b/modules/_bundled/sirsoft-board/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-board", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-board", - "version": "1.0.2", + "version": "1.0.3", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json index 9106b48a..92974da1 100644 --- a/modules/_bundled/sirsoft-board/package.json +++ b/modules/_bundled/sirsoft-board/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-board", - "version": "1.0.2", + "version": "1.0.3", "description": "그누보드7 게시판 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php index 68a1f2cb..cc2e521a 100644 --- a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php +++ b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php @@ -105,8 +105,9 @@ class BoardServiceProvider extends BaseModuleServiceProvider // Sitemap 기여자 등록 $this->app->booted(function () { if ($this->app->bound(SitemapGenerator::class)) { + // Repository 주입을 위해 컨테이너로 해석합니다. $this->app->make(SitemapGenerator::class)->registerContributor( - new BoardSitemapContributor + $this->app->make(BoardSitemapContributor::class) ); } }); diff --git a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php index 2649b453..aa578fc3 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php @@ -2,10 +2,12 @@ namespace Modules\Sirsoft\Board\Repositories; +use App\Enums\UserStatus; use App\Helpers\PermissionHelper; -use App\Helpers\TimezoneHelper; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -32,7 +34,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function create(array $data): Board { - $board = new Board(); + $board = new Board; $board->fill($data); // created_by/updated_by는 guarded이므로 직접 할당 @@ -63,7 +65,7 @@ class BoardRepository implements BoardRepositoryInterface * @param int $id 게시판 ID * @return Board 게시판 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): Board { @@ -88,7 +90,7 @@ class BoardRepository implements BoardRepositoryInterface * @param array $data 수정할 데이터 * @return Board 수정된 게시판 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function update(int $id, array $data): Board { @@ -113,7 +115,7 @@ class BoardRepository implements BoardRepositoryInterface * @param int $id 게시판 ID * @return bool 삭제 성공 여부 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function delete(int $id): bool { @@ -179,7 +181,7 @@ class BoardRepository implements BoardRepositoryInterface /** * 쿼리 빌더를 반환합니다. (체이닝 가능) * - * @return \Illuminate\Database\Eloquent\Builder + * @return Builder */ public function query() { @@ -243,7 +245,7 @@ class BoardRepository implements BoardRepositoryInterface 'board_name' => $post->board?->getLocalizedName(), 'title' => $post->title, 'author_name' => $post->author_name, - 'created_at' => $this->formatCreatedAt($post->created_at), + 'created_at' => $this->formatCreatedAt($post->created_at), 'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')), 'view_count' => $post->view_count, 'comment_count' => $post->comments_count ?? 0, @@ -295,7 +297,7 @@ class BoardRepository implements BoardRepositoryInterface 'id' => $post->id, 'title' => $post->title, 'author_name' => $post->author_name, - 'created_at' => $this->formatCreatedAt($post->created_at), + 'created_at' => $this->formatCreatedAt($post->created_at), 'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')), 'view_count' => $post->view_count, 'comment_count' => $post->comments_count ?? 0, @@ -481,8 +483,8 @@ class BoardRepository implements BoardRepositoryInterface } // 사용자 상태 처리 (탈퇴한 사용자는 익명화) - $userStatus = $post->user_id ? \App\Enums\UserStatus::tryFrom($post->user_status) : null; - $isWithdrawn = $userStatus === \App\Enums\UserStatus::Withdrawn; + $userStatus = $post->user_id ? UserStatus::tryFrom($post->user_status) : null; + $isWithdrawn = $userStatus === UserStatus::Withdrawn; $authorName = $post->user_id ? ($isWithdrawn ? __('user.withdrawn_user') : $post->user_name) : $post->guest_author_name; @@ -504,7 +506,7 @@ class BoardRepository implements BoardRepositoryInterface ], 'view_count' => $viewCount, 'comment_count' => $commentCount, - 'created_at' => $this->formatCreatedAt(Carbon::parse($post->created_at)), + 'created_at' => $this->formatCreatedAt(Carbon::parse($post->created_at)), 'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')), ]; })->toArray(); @@ -513,20 +515,34 @@ class BoardRepository implements BoardRepositoryInterface /** * 활성화된 게시판 목록을 조회합니다. * - * @param string|null $slug 특정 게시판 슬러그 (null이면 전체) + * @param string|null $slug 특정 게시판 슬러그 (null이면 전체) * @return Collection 활성 게시판 컬렉션 */ public function getActiveBoards(?string $slug = null): Collection { $query = Board::where('is_active', true); - if (!empty($slug)) { + if (! empty($slug)) { $query->where('slug', $slug); } return $query->get(); } + /** + * Sitemap 생성용으로 활성 게시판을 순회자로 조회합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 활성 게시판 순회자 (id, slug, updated_at 만 조회) + */ + public function streamActiveForSitemap(int $chunkSize = 500): iterable + { + return Board::where('is_active', true) + ->select(['id', 'slug', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * 필터용 전체 활성 게시판 목록을 반환합니다. * @@ -542,9 +558,9 @@ class BoardRepository implements BoardRepositoryInterface /** * 게시판을 일괄 업데이트합니다. * - * @param array $data 업데이트할 데이터 - * @param bool $applyAll 전체 적용 여부 - * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용) + * @param array $data 업데이트할 데이터 + * @param bool $applyAll 전체 적용 여부 + * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용) * @return int 업데이트된 게시판 수 */ public function bulkUpdate(array $data, bool $applyAll = true, array $boardIds = []): int @@ -590,9 +606,9 @@ class BoardRepository implements BoardRepositoryInterface * * @param string $orderBy 정렬 기준 컬럼 * @param string $orderDirection 정렬 방향 (asc/desc) - * @return \Illuminate\Database\Eloquent\Collection 활성 게시판 컬렉션 + * @return Collection 활성 게시판 컬렉션 */ - public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): \Illuminate\Database\Eloquent\Collection + public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): Collection { return Board::where('is_active', true) ->orderBy($orderBy, $orderDirection) @@ -618,9 +634,9 @@ class BoardRepository implements BoardRepositoryInterface * * id, name, slug 컬럼만 조회하여 메뉴 렌더링에 필요한 최소 데이터만 반환합니다. * - * @return \Illuminate\Database\Eloquent\Collection 활성 게시판 컬렉션 (id, name, slug만 포함) + * @return Collection 활성 게시판 컬렉션 (id, name, slug만 포함) */ - public function getActiveBoardsForMenu(): \Illuminate\Database\Eloquent\Collection + public function getActiveBoardsForMenu(): Collection { return Board::where('is_active', true) ->select(['id', 'name', 'slug']) diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php index e82f5457..b31f471a 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php @@ -2,7 +2,9 @@ namespace Modules\Sirsoft\Board\Repositories\Contracts; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Modules\Sirsoft\Board\Models\Board; @@ -14,7 +16,7 @@ interface BoardRepositoryInterface /** * 게시판을 생성합니다. * - * @param array $data 게시판 생성 데이터 + * @param array $data 게시판 생성 데이터 * @return Board 생성된 게시판 모델 */ public function create(array $data): Board; @@ -22,7 +24,7 @@ interface BoardRepositoryInterface /** * ID로 게시판을 조회합니다. * - * @param int $id 게시판 ID + * @param int $id 게시판 ID * @return Board|null 게시판 모델 또는 null */ public function find(int $id): ?Board; @@ -30,17 +32,17 @@ interface BoardRepositoryInterface /** * ID로 게시판을 조회하며, 없으면 예외를 발생시킵니다. * - * @param int $id 게시판 ID + * @param int $id 게시판 ID * @return Board 게시판 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): Board; /** * 슬러그로 게시판을 조회합니다. * - * @param string $slug 게시판 슬러그 + * @param string $slug 게시판 슬러그 * @return Board|null 게시판 모델 또는 null */ public function findBySlug(string $slug): ?Board; @@ -48,21 +50,21 @@ interface BoardRepositoryInterface /** * 게시판을 수정합니다. * - * @param int $id 게시판 ID - * @param array $data 수정할 데이터 + * @param int $id 게시판 ID + * @param array $data 수정할 데이터 * @return Board 수정된 게시판 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function update(int $id, array $data): Board; /** * 게시판을 삭제합니다. * - * @param int $id 게시판 ID + * @param int $id 게시판 ID * @return bool 삭제 성공 여부 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function delete(int $id): bool; @@ -76,7 +78,7 @@ interface BoardRepositoryInterface /** * 게시판 목록을 페이지네이션하여 조회합니다. * - * @param int $perPage 페이지당 개수 + * @param int $perPage 페이지당 개수 * @return LengthAwarePaginator 페이지네이션된 게시판 목록 */ public function paginate(int $perPage = 15): LengthAwarePaginator; @@ -84,7 +86,7 @@ interface BoardRepositoryInterface /** * 키워드로 게시판을 검색합니다. * - * @param string $keyword 검색 키워드 + * @param string $keyword 검색 키워드 * @return Collection 검색 결과 컬렉션 */ public function search(string $keyword): Collection; @@ -92,7 +94,7 @@ interface BoardRepositoryInterface /** * 타입으로 게시판을 필터링합니다. * - * @param string $type 게시판 타입 + * @param string $type 게시판 타입 * @return Collection 필터링된 게시판 컬렉션 */ public function filterByType(string $type): Collection; @@ -100,7 +102,7 @@ interface BoardRepositoryInterface /** * 쿼리 빌더를 반환합니다. * - * @return \Illuminate\Database\Eloquent\Builder + * @return Builder */ public function query(); @@ -164,11 +166,22 @@ interface BoardRepositoryInterface /** * 활성화된 게시판 목록을 조회합니다. * - * @param string|null $slug 특정 게시판 슬러그 (null이면 전체) + * @param string|null $slug 특정 게시판 슬러그 (null이면 전체) * @return Collection 활성 게시판 컬렉션 */ public function getActiveBoards(?string $slug = null): Collection; + /** + * Sitemap 생성용으로 활성 게시판을 순회자로 조회합니다. + * + * 전체 결과를 메모리에 적재하지 않도록 lazyById 로 청크 단위 조회하며, + * sitemap 에 필요한 컬럼만 select 합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 활성 게시판 순회자 (id, slug, updated_at 만 조회) + */ + public function streamActiveForSitemap(int $chunkSize = 500): iterable; + /** * 필터용 전체 활성 게시판 목록을 반환합니다. * @@ -179,9 +192,9 @@ interface BoardRepositoryInterface /** * 게시판을 일괄 업데이트합니다. * - * @param array $data 업데이트할 데이터 - * @param bool $applyAll 전체 적용 여부 - * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용) + * @param array $data 업데이트할 데이터 + * @param bool $applyAll 전체 적용 여부 + * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용) * @return int 업데이트된 게시판 수 */ public function bulkUpdate(array $data, bool $applyAll = true, array $boardIds = []): int; @@ -189,7 +202,7 @@ interface BoardRepositoryInterface /** * 게시판 ID 목록별 게시글 개수를 조회합니다. * - * @param array $boardIds 게시판 ID 목록 + * @param array $boardIds 게시판 ID 목록 * @return array board_id => count 매핑 */ public function getPostsCountByBoardIds(array $boardIds): array; @@ -197,8 +210,8 @@ interface BoardRepositoryInterface /** * 활성화된 게시판 목록을 정렬 옵션과 함께 조회합니다. * - * @param string $orderBy 정렬 기준 컬럼 - * @param string $orderDirection 정렬 방향 (asc/desc) + * @param string $orderBy 정렬 기준 컬럼 + * @param string $orderDirection 정렬 방향 (asc/desc) * @return Collection 활성 게시판 컬렉션 */ public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): Collection; @@ -222,7 +235,7 @@ interface BoardRepositoryInterface /** * 특정 유형의 게시판 개수를 반환합니다. * - * @param string $type 게시판 유형 slug + * @param string $type 게시판 유형 slug * @return int 해당 유형의 게시판 개수 */ public function countByType(string $type): int; @@ -244,4 +257,4 @@ interface BoardRepositoryInterface * @return int 갱신된 카운트 값 */ public function recalculateCommentsCount(int $boardId): int; -} \ No newline at end of file +} diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php index 88a7c8a2..751c172d 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php @@ -22,6 +22,7 @@ interface PostRepositoryInterface * @param int $perPage 페이지당 항목 수 * @param bool $withTrashed 삭제된 게시글 포함 여부 * @param Board|null $board 게시판 모델 (이미 조회된 경우 전달하여 중복 쿼리 방지) + * @return Paginator 게시글 페이지네이터 */ public function paginate(string $slug, array $filters = [], int $perPage = 15, bool $withTrashed = false, ?Board $board = null): Paginator; @@ -96,6 +97,7 @@ interface PostRepositoryInterface * @param string $status 변경할 상태 (blinded/deleted) * @param array $actionLog 작업 이력 데이터 * @param string|null $triggerType 트리거 유형 (admin, report 등) + * @return Post 상태가 변경된 게시글 모델 * * @throws ModelNotFoundException */ @@ -263,6 +265,18 @@ interface PostRepositoryInterface */ public function findByBoardId(int $boardId, int $id): ?Post; + /** + * Sitemap 용으로 게시판의 공개 게시글을 스트리밍 조회합니다. + * + * 공개 게시글 = 게시 상태 + 비밀글 아님. 전체 적재를 피하기 위해 + * id 기준으로 청크 단위 지연 조회합니다. + * + * @param int $boardId 게시판 ID + * @param int $chunkSize 청크 크기 + * @return iterable 공개 게시글 순회자 (id, updated_at 만 조회) + */ + public function streamPublishedForSitemap(int $boardId, int $chunkSize = 500): iterable; + /** * 게시판 ID 기준으로 게시글을 일괄 소프트 삭제합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php index 40e8fb86..6eb3a160 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php @@ -44,6 +44,7 @@ class PostRepository implements PostRepositoryInterface * @param array $filters 필터 조건 * @param int $perPage 페이지당 항목 수 (일반 게시글 기준) * @param bool $withTrashed 삭제된 게시글 포함 여부 + * @param Board|null $board 게시판 모델 (이미 조회된 경우 전달하여 중복 쿼리 방지) * @return Paginator 페이지네이션된 게시글 목록 (simplePaginate — COUNT 쿼리 제거) */ public function paginate(string $slug, array $filters = [], int $perPage = 15, bool $withTrashed = false, ?Board $board = null): Paginator @@ -176,6 +177,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @return Post|null 게시글 모델 (없으면 null) */ public function find(string $slug, int $id): ?Post { @@ -189,6 +191,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @return Post 게시글 모델 * * @throws ModelNotFoundException */ @@ -205,6 +208,7 @@ class PostRepository implements PostRepositoryInterface * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID * @param array $data 수정할 데이터 + * @return Post 수정된 게시글 모델 * * @throws ModelNotFoundException */ @@ -221,6 +225,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @return bool 삭제 성공 여부 * * @throws ModelNotFoundException */ @@ -236,6 +241,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @return bool 영구 삭제 성공 여부 * * @throws ModelNotFoundException */ @@ -253,6 +259,8 @@ class PostRepository implements PostRepositoryInterface * @param int $id 게시글 ID * @param string $status 변경할 상태 (published/blinded/deleted) * @param array $actionLog 작업 이력 데이터 + * @param string|null $triggerType 트리거 유형 (admin, report 등) + * @return Post 상태가 변경된 게시글 모델 * * @throws ModelNotFoundException */ @@ -406,6 +414,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략) * @return Post|null 게시글 모델 (카운트 포함) */ public function findWithCounts(string $slug, int $id, ?int $boardId = null): ?Post @@ -554,6 +563,7 @@ class PostRepository implements PostRepositoryInterface * @param int $id 현재 게시글 ID * @param array $filters 정렬 파라미터 (order_by, order_direction) * @param bool $withTrashed 삭제된 게시글 포함 여부 (기본: false) + * @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략) * @return array{prev: Post|null, next: Post|null} 이전/다음 게시글 */ public function getAdjacentPosts(string $slug, int $id, array $filters = [], bool $withTrashed = false, ?int $boardId = null): array @@ -1391,6 +1401,27 @@ class PostRepository implements PostRepositoryInterface ->find($id); } + /** + * Sitemap 용으로 게시판의 공개 게시글을 스트리밍 조회합니다. + * + * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로, + * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다. + * + * @param int $boardId 게시판 ID + * @param int $chunkSize 청크 크기 + * @return iterable 공개 게시글 순회자 (id, updated_at 만 조회) + */ + public function streamPublishedForSitemap(int $boardId, int $chunkSize = 500): iterable + { + return Post::query() + ->where('board_id', $boardId) + ->where('status', PostStatus::Published) + ->where('is_secret', false) + ->select(['id', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * 게시판 ID 기준으로 게시글을 일괄 소프트 삭제합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php index 07d7ac83..69d72cf1 100644 --- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php +++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php @@ -3,17 +3,29 @@ namespace Modules\Sirsoft\Board\Seo; use App\Seo\Contracts\SitemapContributorInterface; -use Modules\Sirsoft\Board\Enums\PostStatus; -use Modules\Sirsoft\Board\Models\Board; -use Modules\Sirsoft\Board\Models\Post; +use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; +use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface; /** * Board 모듈 Sitemap 기여자 * * 게시판 및 게시글 URL을 sitemap에 제공합니다. + * 데이터 접근은 Repository 인터페이스에 위임합니다. */ class BoardSitemapContributor implements SitemapContributorInterface { + /** + * BoardSitemapContributor 생성자 + * + * @param BoardRepositoryInterface $boardRepository 게시판 Repository + * @param PostRepositoryInterface $postRepository 게시글 Repository + */ + public function __construct( + private readonly BoardRepositoryInterface $boardRepository, + private readonly PostRepositoryInterface $postRepository, + ) {} + /** * 확장 식별자를 반환합니다. * @@ -35,6 +47,11 @@ class BoardSitemapContributor implements SitemapContributorInterface { $urls = []; + // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 + // 적용된다 — 특정 루프에만 걸면 다른 유형이 상한을 넘겨버린다. + $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); + $truncated = false; + // 'SEO 제공 페이지' 토글 — OFF 시 해당 URL 유형 제외 (기본 노출) $seoBoards = (bool) g7_module_settings('sirsoft-board', 'seo.seo_boards', true); $seoBoard = (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true); @@ -42,11 +59,11 @@ class BoardSitemapContributor implements SitemapContributorInterface // 게시판 목록 페이지 if ($seoBoards) { - $urls[] = [ + $truncated = ! $this->appendUrl($urls, [ 'url' => '/boards', 'changefreq' => 'weekly', 'priority' => 0.5, - ]; + ], $maxUrls); } // 개별 게시판/게시글 URL이 모두 OFF면 게시판 조회 자체를 생략 @@ -55,15 +72,19 @@ class BoardSitemapContributor implements SitemapContributorInterface } // 게시판별 페이지 - $boards = Board::where('is_active', true)->get(['id', 'slug', 'updated_at']); - foreach ($boards as $board) { - if ($seoBoard) { - $urls[] = [ - 'url' => "/board/{$board->slug}", - 'lastmod' => $board->updated_at?->toW3cString(), - 'changefreq' => 'daily', - 'priority' => 0.6, - ]; + foreach ($this->boardRepository->streamActiveForSitemap() as $board) { + if ($truncated) { + break; + } + + if ($seoBoard && ! $this->appendUrl($urls, [ + 'url' => "/board/{$board->slug}", + 'lastmod' => $board->updated_at?->toW3cString(), + 'changefreq' => 'daily', + 'priority' => 0.6, + ], $maxUrls)) { + $truncated = true; + break; } if (! $seoPostDetail) { @@ -71,20 +92,45 @@ class BoardSitemapContributor implements SitemapContributorInterface } // 각 게시판의 공개된 게시글 (비밀글 제외, 게시 상태만) - $posts = Post::where('board_id', $board->id) - ->where('status', PostStatus::Published) - ->where('is_secret', false) - ->get(['id', 'updated_at']); - foreach ($posts as $post) { - $urls[] = [ + foreach ($this->postRepository->streamPublishedForSitemap($board->id) as $post) { + if (! $this->appendUrl($urls, [ 'url' => "/board/{$board->slug}/{$post->id}", 'lastmod' => $post->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, - ]; + ], $maxUrls)) { + $truncated = true; + break; + } } } + if ($truncated) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); + } + return $urls; } + + /** + * 안전 상한을 지키며 URL 항목을 추가합니다. + * + * @param array> $urls 누적 URL 배열 (참조) + * @param array $entry 추가할 URL 항목 + * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) + * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false + */ + private function appendUrl(array &$urls, array $entry, int $maxUrls): bool + { + if ($maxUrls > 0 && count($urls) >= $maxUrls) { + return false; + } + + $urls[] = $entry; + + return true; + } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php new file mode 100644 index 00000000..5e31b53f --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php @@ -0,0 +1,140 @@ + $slug, + 'name' => ['ko' => '레포지토리 사이트맵 테스트', 'en' => 'Repository Sitemap Test'], + 'is_active' => true, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + $this->repository = $this->app->make(BoardRepositoryInterface::class); + } + + /** + * 테스트 정리 - 이 클래스가 생성한 게시판을 제거합니다. + * + * BoardTestCase 는 트랜잭션 롤백 대신 수동 삭제 전략을 쓰며 게시판 자체는 + * 지우지 않는다. 추가 게시판을 만든 쪽이 정리하지 않으면 활성 게시판이 + * 다른 테스트 클래스로 누출되어 게시판 목록 단언을 깨뜨린다. + */ + protected function tearDown(): void + { + Board::where('slug', 'like', 'repo-sitemap-%')->forceDelete(); + + parent::tearDown(); + } + + /** + * streamActiveForSitemap: 전체 적재가 아닌 순회자를 반환한다 + */ + public function test_stream_active_for_sitemap_returns_lazy_collection(): void + { + $this->assertInstanceOf( + LazyCollection::class, + $this->repository->streamActiveForSitemap(), + 'lazyById 순회자여야 전체 적재를 피할 수 있습니다.' + ); + } + + /** + * streamActiveForSitemap: sitemap 에 필요한 컬럼만 적재한다 (회귀) + * + * 범용 getActiveBoards() 는 전컬럼을 적재하므로 sitemap 경로가 그것을 재사용하면 + * 메모리가 후퇴한다. select 가 유지되는지 속성 키로 검증한다. + */ + public function test_stream_active_for_sitemap_selects_only_sitemap_columns(): void + { + $board = $this->repository->streamActiveForSitemap()->first(); + + $this->assertNotNull($board); + $this->assertSame( + ['id', 'slug', 'updated_at'], + array_keys($board->getAttributes()), + 'sitemap 조회는 id/slug/updated_at 만 적재해야 합니다.' + ); + } + + /** + * streamActiveForSitemap: 비활성 게시판은 반환하지 않는다 + */ + public function test_stream_active_for_sitemap_excludes_inactive_boards(): void + { + $this->updateBoardSettings(['is_active' => false]); + + $slugs = collect($this->repository->streamActiveForSitemap()) + ->pluck('slug') + ->all(); + + $this->assertNotContains($this->board->slug, $slugs); + } + + /** + * streamActiveForSitemap: 활성 게시판이 포함된다 + */ + public function test_stream_active_for_sitemap_includes_active_boards(): void + { + $slugs = collect($this->repository->streamActiveForSitemap()) + ->pluck('slug') + ->all(); + + $this->assertContains($this->board->slug, $slugs); + } + + /** + * streamActiveForSitemap: 청크 크기보다 많은 게시판도 전부 순회한다 + */ + public function test_stream_active_for_sitemap_traverses_beyond_chunk_size(): void + { + foreach (range(1, 3) as $i) { + Board::create([ + 'slug' => "repo-sitemap-extra-{$i}", + 'name' => ['ko' => "추가 게시판 {$i}", 'en' => "Extra Board {$i}"], + 'is_active' => true, + ]); + } + + // 청크 크기 1 → 여러 번 쿼리해도 누락 없이 전부 순회해야 한다 + $slugs = collect($this->repository->streamActiveForSitemap(1)) + ->pluck('slug') + ->all(); + + foreach (range(1, 3) as $i) { + $this->assertContains("repo-sitemap-extra-{$i}", $slugs); + } + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php index c974b66f..93c1129e 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Seo; require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Seo\Contracts\SitemapContributorInterface; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Board\Seo\BoardSitemapContributor; @@ -47,7 +48,16 @@ class BoardSitemapContributorTest extends BoardTestCase protected function setUp(): void { parent::setUp(); - $this->contributor = new BoardSitemapContributor; + // Repository 주입이 필요하므로 컨테이너로 해석합니다. + $this->contributor = $this->app->make(BoardSitemapContributor::class); + } + + /** + * SitemapContributorInterface 를 구현한다 + */ + public function test_implements_sitemap_contributor_interface(): void + { + $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); } /** @@ -58,6 +68,51 @@ class BoardSitemapContributorTest extends BoardTestCase $this->assertSame('sirsoft-board', $this->contributor->getIdentifier()); } + /** + * getUrls: 활성 게시판이 없으면 정적 목록 URL만 반환한다 + */ + public function test_get_urls_returns_only_static_url_when_no_active_boards(): void + { + $this->updateBoardSettings(['is_active' => false]); + + $urls = $this->contributor->getUrls(); + + $this->assertSame(['/boards'], array_column($urls, 'url')); + } + + /** + * getUrls: 기여자당 URL 안전 상한을 초과하면 게시글 URL이 잘린다 + */ + public function test_get_urls_truncates_at_max_urls_per_contributor(): void + { + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + + // /boards + /board/{slug} 로 이미 2건이므로 게시글은 1건까지만 담긴다. + // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 3); + + $this->assertCount(3, $this->contributor->getUrls()); + } + + /** + * getUrls: 상한이 게시판 루프에도 적용된다 (회귀) + * + * 상한 검사가 게시글 루프에만 있으면, 게시판 URL 만으로 상한을 초과해도 잘리지 않는다. + */ + public function test_get_urls_truncates_within_board_loop(): void + { + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + + // /boards 1건만 담기면 상한 도달 → 게시판/게시글 URL 은 하나도 담기지 않아야 한다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 1); + + $urls = $this->contributor->getUrls(); + + $this->assertCount(1, $urls, '게시판 루프에서도 상한이 지켜져야 합니다.'); + $this->assertSame('/boards', $urls[0]['url']); + } + /** * getUrls: /boards 항목이 반드시 포함된다 */ diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 6becea4f..2c308f92 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.5] - 2026-07-16 + +### Added + +- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. + +### Changed + +- 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. + ## [1.0.4] - 2026-07-16 ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index 3eda744e..542c3464 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 59be2a50..7f383341 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,7 +5,7 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index 63316555..0b9e70b0 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.0.5", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 746ded67..9e058613 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.0.5", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php index e22a4739..7999c461 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php @@ -218,8 +218,9 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider // Sitemap 기여자 등록 $this->app->booted(function () { if ($this->app->bound(SitemapGenerator::class)) { + // Repository 주입을 위해 컨테이너로 해석합니다. $this->app->make(SitemapGenerator::class)->registerContributor( - new EcommerceSitemapContributor + $this->app->make(EcommerceSitemapContributor::class) ); } }); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php index deaad41b..b0357fd8 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php @@ -15,6 +15,24 @@ class CategoryRepository implements CategoryRepositoryInterface protected Category $model ) {} + /** + * Sitemap 용으로 활성 카테고리를 스트리밍 조회합니다. + * + * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로, + * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 활성 카테고리 순회자 (id, slug, updated_at 만 조회) + */ + public function streamActiveForSitemap(int $chunkSize = 500): iterable + { + return $this->model->newQuery() + ->where('is_active', true) + ->select(['id', 'slug', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * {@inheritDoc} */ @@ -76,7 +94,7 @@ class CategoryRepository implements CategoryRepositoryInterface { $query = $this->model->newQuery(); - if (!empty($with)) { + if (! empty($with)) { $query->with($with); } @@ -101,6 +119,7 @@ class CategoryRepository implements CategoryRepositoryInterface { $category = $this->findById($id); $category->update($data); + return $category->fresh(); } @@ -110,6 +129,7 @@ class CategoryRepository implements CategoryRepositoryInterface public function delete(int $id): bool { $category = $this->findById($id); + return $category->delete(); } @@ -127,6 +147,7 @@ class CategoryRepository implements CategoryRepositoryInterface public function getProductCount(int $id): int { $category = $this->findById($id); + return $category->products()->count(); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php index da2caf18..6762b532 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php @@ -13,94 +13,104 @@ interface CategoryRepositoryInterface /** * 계층형 카테고리 목록 조회 * - * @param array $filters 필터 조건 - * @param array $with Eager loading 관계 - * @return Collection + * @param array $filters 필터 조건 + * @param array $with Eager loading 관계 + * @return Collection 계층형 카테고리 목록 */ public function getHierarchical(array $filters = [], array $with = []): Collection; /** * ID로 카테고리 조회 * - * @param int $id 카테고리 ID - * @param array $with Eager loading 관계 - * @return Category|null + * @param int $id 카테고리 ID + * @param array $with Eager loading 관계 + * @return Category|null 카테고리 모델 (없으면 null) */ public function findById(int $id, array $with = []): ?Category; /** * 카테고리 생성 * - * @param array $data 카테고리 데이터 - * @return Category + * @param array $data 카테고리 데이터 + * @return Category 생성된 카테고리 모델 */ public function create(array $data): Category; /** * 카테고리 수정 * - * @param int $id 카테고리 ID - * @param array $data 수정 데이터 - * @return Category + * @param int $id 카테고리 ID + * @param array $data 수정 데이터 + * @return Category 수정된 카테고리 모델 */ public function update(int $id, array $data): Category; /** * 카테고리 삭제 * - * @param int $id 카테고리 ID - * @return bool + * @param int $id 카테고리 ID + * @return bool 삭제 성공 여부 */ public function delete(int $id): bool; /** * 하위 카테고리 존재 여부 확인 * - * @param int $id 카테고리 ID - * @return bool + * @param int $id 카테고리 ID + * @return bool 하위 카테고리 존재 여부 */ public function hasChildren(int $id): bool; /** * 연결된 상품 수 조회 * - * @param int $id 카테고리 ID - * @return int + * @param int $id 카테고리 ID + * @return int 연결된 상품 수 */ public function getProductCount(int $id): int; /** * 다음 정렬 순서 값 조회 * - * @param int|null $parentId 부모 카테고리 ID - * @return int + * @param int|null $parentId 부모 카테고리 ID + * @return int 다음 정렬 순서 값 */ public function getNextSortOrder(?int $parentId = null): int; /** * 슬러그 중복 확인 * - * @param string $slug 슬러그 - * @param int|null $excludeId 제외할 카테고리 ID - * @return bool + * @param string $slug 슬러그 + * @param int|null $excludeId 제외할 카테고리 ID + * @return bool 슬러그 중복 여부 */ public function existsBySlug(string $slug, ?int $excludeId = null): bool; /** * slug로 카테고리 조회 * - * @param string $slug 카테고리 slug - * @param array $with Eager loading 관계 - * @return Category|null + * @param string $slug 카테고리 slug + * @param array $with Eager loading 관계 + * @return Category|null 카테고리 모델 (없으면 null) */ public function findBySlug(string $slug, array $with = []): ?Category; /** * 평면 리스트로 카테고리 목록 조회 (TagInput 등에 사용) * - * @param array $filters 필터 조건 - * @param array $with Eager loading 관계 - * @return Collection + * @param array $filters 필터 조건 + * @param array $with Eager loading 관계 + * @return Collection 평면 카테고리 목록 */ public function getFlatList(array $filters = [], array $with = []): Collection; + + /** + * Sitemap 용으로 활성 카테고리를 스트리밍 조회합니다. + * + * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 활성 카테고리 순회자 (id, slug, updated_at 만 조회) + */ + public function streamActiveForSitemap(int $chunkSize = 500): iterable; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php index 397b02ff..3c9526ff 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php @@ -29,6 +29,16 @@ interface ProductRepositoryInterface */ public function existsAny(): bool; + /** + * Sitemap 용으로 전시 중인 상품을 스트리밍 조회합니다. + * + * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 전시 중인 상품 순회자 (id, updated_at 만 조회) + */ + public function streamVisibleForSitemap(int $chunkSize = 500): iterable; + /** * 필터링된 상품 목록 조회 (페이지네이션) * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php index 7020dd7b..28480573 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php @@ -32,6 +32,24 @@ class ProductRepository implements ProductRepositoryInterface return $this->model->newQuery()->withTrashed()->exists(); } + /** + * Sitemap 용으로 전시 중인 상품을 스트리밍 조회합니다. + * + * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로, + * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 전시 중인 상품 순회자 (id, updated_at 만 조회) + */ + public function streamVisibleForSitemap(int $chunkSize = 500): iterable + { + return $this->model->newQuery() + ->where('display_status', ProductDisplayStatus::VISIBLE->value) + ->select(['id', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php index 59f8a540..c1624102 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php @@ -3,17 +3,29 @@ namespace Modules\Sirsoft\Ecommerce\Seo; use App\Seo\Contracts\SitemapContributorInterface; -use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; -use Modules\Sirsoft\Ecommerce\Models\Category; -use Modules\Sirsoft\Ecommerce\Models\Product; +use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; /** * Ecommerce 모듈 Sitemap 기여자 * * 상품 및 카테고리 URL을 sitemap에 제공합니다. + * 데이터 접근은 Repository 인터페이스에 위임합니다. */ class EcommerceSitemapContributor implements SitemapContributorInterface { + /** + * EcommerceSitemapContributor 생성자 + * + * @param CategoryRepositoryInterface $categoryRepository 카테고리 Repository + * @param ProductRepositoryInterface $productRepository 상품 Repository + */ + public function __construct( + private readonly CategoryRepositoryInterface $categoryRepository, + private readonly ProductRepositoryInterface $productRepository, + ) {} + /** * 확장 식별자를 반환합니다. * @@ -36,42 +48,76 @@ class EcommerceSitemapContributor implements SitemapContributorInterface $urls = []; $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop'; + // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 + // 적용된다 — 특정 루프에만 걸면 다른 유형이 상한을 넘겨버린다. + $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); + $truncated = false; + // 상품 목록 페이지 — 'SEO 제공 페이지' 토글 OFF 시 제외 if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) { - $urls[] = [ + $truncated = ! $this->appendUrl($urls, [ 'url' => "/{$routePath}/products", 'changefreq' => 'daily', 'priority' => 0.7, - ]; + ], $maxUrls); } // 카테고리별 페이지 — 토글 OFF 시 제외 - if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) { - $categories = Category::where('is_active', true)->get(['id', 'slug', 'updated_at']); - foreach ($categories as $category) { - $urls[] = [ + if (! $truncated && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) { + foreach ($this->categoryRepository->streamActiveForSitemap() as $category) { + if (! $this->appendUrl($urls, [ 'url' => "/{$routePath}/category/{$category->slug}", 'lastmod' => $category->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.6, - ]; + ], $maxUrls)) { + $truncated = true; + break; + } } } // 개별 상품 페이지 (전시 상태가 '전시'인 상품만) — 토글 OFF 시 제외 - if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) { - $products = Product::where('display_status', ProductDisplayStatus::VISIBLE) - ->get(['id', 'updated_at']); - foreach ($products as $product) { - $urls[] = [ + if (! $truncated && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) { + foreach ($this->productRepository->streamVisibleForSitemap() as $product) { + if (! $this->appendUrl($urls, [ 'url' => "/{$routePath}/products/{$product->id}", 'lastmod' => $product->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.8, - ]; + ], $maxUrls)) { + $truncated = true; + break; + } } } + if ($truncated) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); + } + return $urls; } + + /** + * 안전 상한을 지키며 URL 항목을 추가합니다. + * + * @param array> $urls 누적 URL 배열 (참조) + * @param array $entry 추가할 URL 항목 + * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) + * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false + */ + private function appendUrl(array &$urls, array $entry, int $maxUrls): bool + { + if ($maxUrls > 0 && count($urls) >= $maxUrls) { + return false; + } + + $urls[] = $entry; + + return true; + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php index 8abb37d6..53a62b26 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Seo; require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Seo\Contracts\SitemapContributorInterface; use Illuminate\Support\Facades\Config; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Models\Product; @@ -31,7 +32,8 @@ class EcommerceSitemapContributorTest extends ModuleTestCase protected function setUp(): void { parent::setUp(); - $this->contributor = new EcommerceSitemapContributor; + // Repository 주입이 필요하므로 컨테이너로 해석합니다. + $this->contributor = $this->app->make(EcommerceSitemapContributor::class); $this->routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop'; } @@ -50,6 +52,14 @@ class EcommerceSitemapContributorTest extends ModuleTestCase ]); } + /** + * SitemapContributorInterface 를 구현한다 + */ + public function test_implements_sitemap_contributor_interface(): void + { + $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); + } + /** * getIdentifier: 'sirsoft-ecommerce' 반환 */ @@ -58,6 +68,96 @@ class EcommerceSitemapContributorTest extends ModuleTestCase $this->assertSame('sirsoft-ecommerce', $this->contributor->getIdentifier()); } + /** + * getUrls: route_path 미설정 시 기본값 'shop' 을 사용한다 + */ + public function test_get_urls_uses_default_route_path_when_not_configured(): void + { + Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', null); + + $contributor = $this->app->make(EcommerceSitemapContributor::class); + $urlPaths = array_column($contributor->getUrls(), 'url'); + + $this->assertContains('/shop/products', $urlPaths); + } + + /** + * getUrls: 정적/카테고리/상품 URL 항목이 각각 올바른 키 구조를 가진다 + */ + public function test_url_entries_have_correct_structure(): void + { + $category = $this->createTestCategory(); + $product = Product::factory()->create(); + + $urls = collect($this->contributor->getUrls()); + + // 정적 목록 URL — lastmod 없음 + $index = $urls->firstWhere('url', "/{$this->routePath}/products"); + $this->assertNotNull($index); + $this->assertSame('daily', $index['changefreq']); + $this->assertSame(0.7, $index['priority']); + $this->assertArrayNotHasKey('lastmod', $index); + + // 카테고리 URL — lastmod 있음 + $categoryEntry = $urls->firstWhere('url', "/{$this->routePath}/category/{$category->slug}"); + $this->assertNotNull($categoryEntry); + $this->assertArrayHasKey('lastmod', $categoryEntry); + $this->assertSame('weekly', $categoryEntry['changefreq']); + $this->assertSame(0.6, $categoryEntry['priority']); + + // 상품 URL — lastmod 있음 + $productEntry = $urls->firstWhere('url', "/{$this->routePath}/products/{$product->id}"); + $this->assertNotNull($productEntry); + $this->assertArrayHasKey('lastmod', $productEntry); + $this->assertSame('weekly', $productEntry['changefreq']); + $this->assertSame(0.8, $productEntry['priority']); + } + + /** + * getUrls: 기여자당 URL 안전 상한을 초과하면 상품 URL이 잘린다 + */ + public function test_get_urls_truncates_at_max_urls_per_contributor(): void + { + Product::factory()->count(3)->create(); + + // 정적 목록 URL 1건이 이미 담기므로 상품은 1건까지만 담긴다. + // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2); + + $this->assertCount(2, $this->contributor->getUrls()); + } + + /** + * getUrls: 상한이 카테고리 루프에도 적용된다 (회귀) + * + * 상한 검사가 상품 루프에만 있으면, 카테고리만으로 상한을 초과해도 잘리지 않는다. + */ + public function test_get_urls_truncates_within_category_loop(): void + { + $this->createTestCategory(); + Category::create([ + 'name' => ['ko' => '사이트맵 카테고리2', 'en' => 'Sitemap Category 2'], + 'slug' => 'sitemap-cat-2', + 'is_active' => true, + 'path' => '/', + 'depth' => 0, + 'sort_order' => 1, + ]); + Product::factory()->count(2)->create(); + + // 정적 목록 1건 + 카테고리 2건 = 3건이지만 상한은 2건. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2); + + $urls = $this->contributor->getUrls(); + + $this->assertCount(2, $urls, '카테고리 루프에서도 상한이 지켜져야 합니다.'); + // 상한 도달 후에는 상품 URL 이 하나도 담기지 않아야 한다 + $this->assertEmpty( + array_filter($urls, fn (array $u): bool => str_contains($u['url'], '/products/')), + '상한 도달 후 상품 URL 이 추가되면 안 됩니다.' + ); + } + /** * getUrls: 기본(토글 ON) 상태에서 목록/카테고리/상품 URL이 모두 포함된다 (비파괴 회귀) */ diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json index 7af6ba79..e8f1c775 100644 --- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json +++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json @@ -1,15 +1,15 @@ { "schema_version": "1.0", - "generated_at": "2026-07-14T14:08:36+00:00", + "generated_at": "2026-07-16T15:36:56+00:00", "generator": "g7 vendor-bundle:build", "target": "module:sirsoft-ecommerce", - "composer_json_sha256": "a90f1bb9cceeb9542e84ca09a0945f31089879fdbb566044364058332627998a", + "composer_json_sha256": "892a78a4c180c410eeb2cafbdec3ee1ff87ea066db94dea7e1340678e91759f6", "composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d", - "zip_sha256": "ffc7123cd61c7ca13a6dc29325aa31454005affd59fe97acf372d30ab016475d", + "zip_sha256": "e222da279ba452bd155fb68f06d6bcdd0c2f29041f231eeea3ef6229912e9dcc", "zip_size": 435549, "package_count": 1, "php_requirement": "^8.2", - "g7_version": "7.0.4", + "g7_version": "7.0.5", "packages": [ { "name": "ezyang/htmlpurifier", diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip index b3698cc8..a8a0cc92 100644 Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 11753b65..b2367b01 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-16 + +### Added + +- 사이트맵에 담을 페이지 수의 안전 상한을 지원합니다. 상한을 설정하면 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. + +### Changed + +- 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. + ## [1.0.1] - 2026-07-08 ### Fixed diff --git a/modules/_bundled/sirsoft-page/composer.json b/modules/_bundled/sirsoft-page/composer.json index bb65c272..49e93a38 100644 --- a/modules/_bundled/sirsoft-page/composer.json +++ b/modules/_bundled/sirsoft-page/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-page", "description": "Page module for Gnuboard7", "type": "library", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json index 93be60e8..78007a38 100644 --- a/modules/_bundled/sirsoft-page/module.json +++ b/modules/_bundled/sirsoft-page/module.json @@ -5,7 +5,7 @@ "ko": "페이지", "en": "Page" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "description": { "ko": "정적 페이지(정보/정책/안내) 관리 모듈", diff --git a/modules/_bundled/sirsoft-page/package-lock.json b/modules/_bundled/sirsoft-page/package-lock.json index 79e30c54..f4ab96db 100644 --- a/modules/_bundled/sirsoft-page/package-lock.json +++ b/modules/_bundled/sirsoft-page/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-page", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-page", - "version": "1.0.1", + "version": "1.0.2", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-page/package.json b/modules/_bundled/sirsoft-page/package.json index 070d07f5..6de095d7 100644 --- a/modules/_bundled/sirsoft-page/package.json +++ b/modules/_bundled/sirsoft-page/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-page", - "version": "1.0.1", + "version": "1.0.2", "description": "그누보드7 페이지 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php index b50de7e5..8f16dda5 100644 --- a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php +++ b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php @@ -63,8 +63,9 @@ class PageServiceProvider extends BaseModuleServiceProvider // Sitemap 기여자 등록 $this->app->booted(function () { if ($this->app->bound(SitemapGenerator::class)) { + // Repository 주입을 위해 컨테이너로 해석합니다. $this->app->make(SitemapGenerator::class)->registerContributor( - new PageSitemapContributor + $this->app->make(PageSitemapContributor::class) ); } }); diff --git a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php index 9abd3586..3635fd25 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php @@ -2,6 +2,8 @@ namespace Modules\Sirsoft\Page\Repositories\Contracts; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Modules\Sirsoft\Page\Models\Page; @@ -35,13 +37,23 @@ interface PageRepositoryInterface */ public function findBySlug(string $slug): ?Page; + /** + * Sitemap 용으로 발행된 페이지를 스트리밍 조회합니다. + * + * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 발행된 페이지 순회자 (id, slug, updated_at 만 조회) + */ + public function streamPublishedForSitemap(int $chunkSize = 500): iterable; + /** * ID로 페이지를 조회하며, 없으면 예외를 발생시킵니다. * * @param int $id 페이지 ID * @return Page 페이지 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): Page; @@ -86,7 +98,7 @@ interface PageRepositoryInterface * @param string $orderBy 정렬 컬럼 * @param string $direction 정렬 방향 (asc, desc) * @param int $limit 조회할 최대 항목 수 - * @return array{total: int, items: \Illuminate\Database\Eloquent\Collection} + * @return array{total: int, items: Collection} */ public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array; diff --git a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php index ac937438..be5c37f5 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php @@ -72,6 +72,24 @@ class PageRepository implements PageRepositoryInterface return Page::where('slug', $slug)->first(); } + /** + * Sitemap 용으로 발행된 페이지를 스트리밍 조회합니다. + * + * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로, + * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 발행된 페이지 순회자 (id, slug, updated_at 만 조회) + */ + public function streamPublishedForSitemap(int $chunkSize = 500): iterable + { + return Page::query() + ->where('published', true) + ->select(['id', 'slug', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * ID로 페이지를 조회하며, 없으면 예외를 발생시킵니다. * diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php index 4d6fb2c9..4860da75 100644 --- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php +++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php @@ -3,15 +3,26 @@ namespace Modules\Sirsoft\Page\Seo; use App\Seo\Contracts\SitemapContributorInterface; -use Modules\Sirsoft\Page\Models\Page; +use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; /** * Page 모듈 Sitemap 기여자 * * 발행된 페이지 URL을 sitemap에 제공합니다. + * 데이터 접근은 Repository 인터페이스에 위임합니다. */ class PageSitemapContributor implements SitemapContributorInterface { + /** + * PageSitemapContributor 생성자 + * + * @param PageRepositoryInterface $pageRepository 페이지 Repository + */ + public function __construct( + private readonly PageRepositoryInterface $pageRepository, + ) {} + /** * 확장 식별자를 반환합니다. * @@ -33,16 +44,48 @@ class PageSitemapContributor implements SitemapContributorInterface { $urls = []; - $pages = Page::where('published', true)->get(['slug', 'updated_at']); - foreach ($pages as $page) { - $urls[] = [ + // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 적용된다. + $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); + $truncated = false; + + foreach ($this->pageRepository->streamPublishedForSitemap() as $page) { + if (! $this->appendUrl($urls, [ 'url' => "/page/{$page->slug}", 'lastmod' => $page->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, - ]; + ], $maxUrls)) { + $truncated = true; + break; + } + } + + if ($truncated) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); } return $urls; } + + /** + * 안전 상한을 지키며 URL 항목을 추가합니다. + * + * @param array> $urls 누적 URL 배열 (참조) + * @param array $entry 추가할 URL 항목 + * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) + * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false + */ + private function appendUrl(array &$urls, array $entry, int $maxUrls): bool + { + if ($maxUrls > 0 && count($urls) >= $maxUrls) { + return false; + } + + $urls[] = $entry; + + return true; + } } diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php new file mode 100644 index 00000000..fb4bb299 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php @@ -0,0 +1,120 @@ +contributor = $this->app->make(PageSitemapContributor::class); + } + + /** + * SitemapContributorInterface 를 구현한다 + */ + public function test_implements_sitemap_contributor_interface(): void + { + $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); + } + + /** + * getIdentifier: 'sirsoft-page' 반환 + */ + public function test_get_identifier_returns_sirsoft_page(): void + { + $this->assertSame('sirsoft-page', $this->contributor->getIdentifier()); + } + + /** + * getUrls: 발행된 페이지 URL이 포함된다 + */ + public function test_get_urls_includes_published_page(): void + { + $page = Page::factory()->published()->create(); + + $urlPaths = array_column($this->contributor->getUrls(), 'url'); + + $this->assertContains("/page/{$page->slug}", $urlPaths); + } + + /** + * getUrls: 미발행 페이지는 포함되지 않는다 + */ + public function test_get_urls_excludes_unpublished_page(): void + { + $page = Page::factory()->create(['published' => false]); + + $urlPaths = array_column($this->contributor->getUrls(), 'url'); + + $this->assertNotContains("/page/{$page->slug}", $urlPaths); + } + + /** + * getUrls: URL 항목이 올바른 키 구조를 가진다 + */ + public function test_url_entries_have_correct_structure(): void + { + $page = Page::factory()->published()->create(); + + $urls = $this->contributor->getUrls(); + $entry = collect($urls)->firstWhere('url', "/page/{$page->slug}"); + + $this->assertNotNull($entry); + $this->assertArrayHasKey('lastmod', $entry); + $this->assertSame('monthly', $entry['changefreq']); + $this->assertSame(0.5, $entry['priority']); + } + + /** + * getUrls: 발행된 페이지가 없으면 빈 배열을 반환한다 + */ + public function test_get_urls_returns_empty_array_when_no_published_pages(): void + { + Page::factory()->count(2)->create(['published' => false]); + + $this->assertSame([], $this->contributor->getUrls()); + } + + /** + * getUrls: 기여자당 URL 안전 상한을 초과하면 잘린다 + */ + public function test_get_urls_truncates_at_max_urls_per_contributor(): void + { + Page::factory()->count(3)->published()->create(); + + // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2); + + $this->assertCount(2, $this->contributor->getUrls()); + } +} diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index de189905..4ea72464 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1406,9 +1406,9 @@ "seo_cache_settings": "SEO Cache Settings", "seo_cache_settings_desc": "Configure SEO page caching.", "cache_enabled": "Enable SEO Cache", - "cache_enabled_desc": "Cache SEO pages to improve response time.", + "cache_enabled_desc": "Cache SEO pages to improve response time. Leave unset to follow the value in Advanced settings.", "cache_ttl": "Cache TTL (seconds)", - "cache_ttl_hint": "Min 60s, max 86400s (24 hours). Default: 7200s (2 hours)", + "cache_ttl_hint": "Leave empty to follow the SEO cache TTL in Advanced settings. Enter a value to override it here — min 60s, max 86400s (24 hours)", "sitemap_settings": "Sitemap Settings", "sitemap_settings_desc": "Configure automatic sitemap.xml generation.", "sitemap_enabled": "Enable Sitemap Generation", @@ -1420,7 +1420,7 @@ "sitemap_schedule_time": "Generation Time", "sitemap_schedule_time_hint": "Enter in HH:mm format. (e.g., 02:00)", "sitemap_cache_ttl": "Sitemap Cache TTL (seconds)", - "sitemap_cache_ttl_hint": "Min 3600s (1 hour), max 604800s (7 days). Default: 86400s (24 hours)", + "sitemap_cache_ttl_hint": "Leave empty to follow the sitemap cache TTL in Advanced settings. Enter a value to override it here — min 3600s (1 hour), max 604800s (7 days)", "sitemap_last_updated_at": "Last generated", "sitemap_manual_update": "Generate now", "sitemap_updating": "Generating...", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 9bc4afec..fff2e289 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1410,9 +1410,9 @@ "seo_cache_settings": "SEO 캐시 설정", "seo_cache_settings_desc": "SEO 페이지 캐시 관련 설정입니다.", "cache_enabled": "SEO 캐시 활성화", - "cache_enabled_desc": "SEO 페이지를 캐시하여 응답 속도를 향상시킵니다.", + "cache_enabled_desc": "SEO 페이지를 캐시하여 응답 속도를 향상시킵니다. 지정하지 않으면 고급 설정의 값을 따릅니다.", "cache_ttl": "캐시 유지 시간 (초)", - "cache_ttl_hint": "최소 60초, 최대 86400초 (24시간). 기본값: 7200초 (2시간)", + "cache_ttl_hint": "비워두면 고급 설정의 SEO 캐시 시간을 따릅니다. 값을 넣으면 이 화면의 값이 우선합니다 — 최소 60초, 최대 86400초 (24시간)", "sitemap_settings": "Sitemap 설정", "sitemap_settings_desc": "sitemap.xml 자동 생성 관련 설정입니다.", "sitemap_enabled": "Sitemap 생성 활성화", @@ -1424,7 +1424,7 @@ "sitemap_schedule_time": "생성 시각", "sitemap_schedule_time_hint": "HH:mm 형식으로 입력하세요. (예: 02:00)", "sitemap_cache_ttl": "Sitemap 캐시 유지 시간 (초)", - "sitemap_cache_ttl_hint": "최소 3600초 (1시간), 최대 604800초 (7일). 기본값: 86400초 (24시간)", + "sitemap_cache_ttl_hint": "비워두면 고급 설정의 Sitemap 캐시 시간을 따릅니다. 값을 넣으면 이 화면의 값이 우선합니다 — 최소 3600초 (1시간), 최대 604800초 (7일)", "sitemap_last_updated_at": "마지막 생성", "sitemap_manual_update": "지금 생성", "sitemap_updating": "생성 중...", diff --git a/tests/Unit/Seo/BoardSitemapContributorTest.php b/tests/Unit/Seo/BoardSitemapContributorTest.php deleted file mode 100644 index 03722020..00000000 --- a/tests/Unit/Seo/BoardSitemapContributorTest.php +++ /dev/null @@ -1,181 +0,0 @@ -contributor = new BoardSitemapContributor; - } - - /** - * 테스트 종료 - Mockery 리소스를 정리합니다. - */ - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * SitemapContributorInterface를 구현하는지 확인합니다. - */ - public function test_implements_sitemap_contributor_interface(): void - { - $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); - } - - /** - * getIdentifier()가 'sirsoft-board'를 반환하는지 확인합니다. - */ - public function test_get_identifier_returns_correct_value(): void - { - $this->assertSame('sirsoft-board', $this->contributor->getIdentifier()); - } - - /** - * getUrls()가 게시판/게시글 URL을 포함한 배열을 반환하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_board_and_post_urls(): void - { - // Board::where('is_active', true)->get() Mock - $boardCollection = collect([ - (object) ['id' => 1, 'slug' => 'notice', 'updated_at' => now()], - (object) ['id' => 2, 'slug' => 'free', 'updated_at' => now()], - ]); - - $boardQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $boardQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn($boardCollection); - - $boardMock = Mockery::mock('alias:'.Board::class); - $boardMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($boardQuery); - - // Post::where(...)->where(...)->where(...)->get() Mock - notice 게시판 - $noticePostCollection = collect([ - (object) ['id' => 100, 'updated_at' => now()], - ]); - - // Post::where(...)->where(...)->where(...)->get() Mock - free 게시판 - $freePostCollection = collect([ - (object) ['id' => 200, 'updated_at' => now()], - (object) ['id' => 201, 'updated_at' => now()], - ]); - - // Post 쿼리 빌더 체이닝 Mock - $postQueryNotice = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $postQueryNotice->shouldReceive('where') - ->with('status', PostStatus::Published) - ->andReturnSelf(); - $postQueryNotice->shouldReceive('where') - ->with('is_secret', false) - ->andReturnSelf(); - $postQueryNotice->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn($noticePostCollection); - - $postQueryFree = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $postQueryFree->shouldReceive('where') - ->with('status', PostStatus::Published) - ->andReturnSelf(); - $postQueryFree->shouldReceive('where') - ->with('is_secret', false) - ->andReturnSelf(); - $postQueryFree->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn($freePostCollection); - - $postMock = Mockery::mock('alias:'.Post::class); - $postMock->shouldReceive('where') - ->with('board_id', 1) - ->andReturn($postQueryNotice); - $postMock->shouldReceive('where') - ->with('board_id', 2) - ->andReturn($postQueryFree); - - $contributor = new BoardSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertIsArray($urls); - - // 1(정적 목록) + 2(게시판) + 1(notice 게시글) + 2(free 게시글) = 6 - $this->assertCount(6, $urls); - - // 정적 게시판 목록 URL - $this->assertSame('/boards', $urls[0]['url']); - $this->assertSame('weekly', $urls[0]['changefreq']); - $this->assertSame(0.5, $urls[0]['priority']); - - // notice 게시판 URL - $this->assertSame('/board/notice', $urls[1]['url']); - $this->assertSame('daily', $urls[1]['changefreq']); - $this->assertSame(0.6, $urls[1]['priority']); - - // notice 게시판의 게시글 URL - $this->assertSame('/board/notice/100', $urls[2]['url']); - $this->assertSame('monthly', $urls[2]['changefreq']); - $this->assertSame(0.5, $urls[2]['priority']); - - // free 게시판 URL - $this->assertSame('/board/free', $urls[3]['url']); - - // free 게시판의 게시글 URL - $this->assertSame('/board/free/200', $urls[4]['url']); - $this->assertSame('/board/free/201', $urls[5]['url']); - } - - /** - * 활성 게시판이 없을 때 정적 URL만 반환하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_only_static_url_when_no_active_boards(): void - { - $boardQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $boardQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn(collect([])); - - $boardMock = Mockery::mock('alias:'.Board::class); - $boardMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($boardQuery); - - $contributor = new BoardSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertCount(1, $urls); - $this->assertSame('/boards', $urls[0]['url']); - } -} diff --git a/tests/Unit/Seo/EcommerceSitemapContributorTest.php b/tests/Unit/Seo/EcommerceSitemapContributorTest.php deleted file mode 100644 index 0a818cc8..00000000 --- a/tests/Unit/Seo/EcommerceSitemapContributorTest.php +++ /dev/null @@ -1,233 +0,0 @@ -contributor = new EcommerceSitemapContributor; - } - - /** - * 테스트 종료 - Mockery 리소스를 정리합니다. - */ - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * SitemapContributorInterface를 구현하는지 확인합니다. - */ - public function test_implements_sitemap_contributor_interface(): void - { - $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); - } - - /** - * getIdentifier()가 'sirsoft-ecommerce'를 반환하는지 확인합니다. - */ - public function test_get_identifier_returns_correct_value(): void - { - $this->assertSame('sirsoft-ecommerce', $this->contributor->getIdentifier()); - } - - /** - * getUrls()가 배열을 반환하는지 확인합니다. - * - * Category/Product 모델 쿼리를 Mock하여 DB 의존성 없이 테스트합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_array_with_mocked_models(): void - { - // Category::where('is_active', true)->get() Mock - $categoryCollection = collect([ - (object) ['id' => 1, 'slug' => 'electronics', 'updated_at' => now()], - (object) ['id' => 2, 'slug' => 'fashion', 'updated_at' => now()], - ]); - - $categoryQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $categoryQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn($categoryCollection); - - // Category 모델 정적 호출 Mock (alias) - $categoryMock = Mockery::mock('alias:'.Category::class); - $categoryMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($categoryQuery); - - // Product::where('display_status', ProductDisplayStatus::VISIBLE)->get() Mock - $productCollection = collect([ - (object) ['id' => 10, 'updated_at' => now()], - (object) ['id' => 20, 'updated_at' => now()], - ]); - - $productQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $productQuery->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn($productCollection); - - $productMock = Mockery::mock('alias:'.Product::class); - $productMock->shouldReceive('where') - ->with('display_status', ProductDisplayStatus::VISIBLE) - ->andReturn($productQuery); - - // route_path 설정 - $this->app['config']->set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', 'shop'); - - $contributor = new EcommerceSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertIsArray($urls); - $this->assertNotEmpty($urls); - - // 첫 번째는 정적 상품 목록 URL - $this->assertSame('/shop/products', $urls[0]['url']); - $this->assertSame('daily', $urls[0]['changefreq']); - $this->assertSame(0.7, $urls[0]['priority']); - - // 카테고리 URL 확인 (2개) - $this->assertSame('/shop/category/electronics', $urls[1]['url']); - $this->assertSame('/shop/category/fashion', $urls[2]['url']); - - // 상품 URL 확인 (2개) - $this->assertSame('/shop/products/10', $urls[3]['url']); - $this->assertSame('/shop/products/20', $urls[4]['url']); - - // 총 5개 URL (1 정적 + 2 카테고리 + 2 상품) - $this->assertCount(5, $urls); - } - - /** - * getUrls()가 route_path 기본값 'shop'을 사용하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_uses_default_route_path_when_not_configured(): void - { - // Category 빈 컬렉션 반환 - $categoryQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $categoryQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn(collect([])); - - $categoryMock = Mockery::mock('alias:'.Category::class); - $categoryMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($categoryQuery); - - // Product 빈 컬렉션 반환 - $productQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $productQuery->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn(collect([])); - - $productMock = Mockery::mock('alias:'.Product::class); - $productMock->shouldReceive('where') - ->with('display_status', ProductDisplayStatus::VISIBLE) - ->andReturn($productQuery); - - // route_path 미설정 (기본값 'shop' 사용) - $this->app['config']->set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', null); - - $contributor = new EcommerceSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertCount(1, $urls); - $this->assertSame('/shop/products', $urls[0]['url']); - } - - /** - * 각 URL 항목이 올바른 키 구조를 가지는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_url_entries_have_correct_structure(): void - { - $categoryCollection = collect([ - (object) ['id' => 1, 'slug' => 'test-cat', 'updated_at' => now()], - ]); - - $categoryQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $categoryQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn($categoryCollection); - - $categoryMock = Mockery::mock('alias:'.Category::class); - $categoryMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($categoryQuery); - - $productCollection = collect([ - (object) ['id' => 5, 'updated_at' => now()], - ]); - - $productQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $productQuery->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn($productCollection); - - $productMock = Mockery::mock('alias:'.Product::class); - $productMock->shouldReceive('where') - ->with('display_status', ProductDisplayStatus::VISIBLE) - ->andReturn($productQuery); - - $this->app['config']->set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', 'shop'); - - $contributor = new EcommerceSitemapContributor; - $urls = $contributor->getUrls(); - - // 정적 URL 구조 확인 (lastmod 없음) - $this->assertArrayHasKey('url', $urls[0]); - $this->assertArrayHasKey('changefreq', $urls[0]); - $this->assertArrayHasKey('priority', $urls[0]); - $this->assertArrayNotHasKey('lastmod', $urls[0]); - - // 카테고리 URL 구조 확인 (lastmod 있음) - $this->assertArrayHasKey('url', $urls[1]); - $this->assertArrayHasKey('lastmod', $urls[1]); - $this->assertArrayHasKey('changefreq', $urls[1]); - $this->assertArrayHasKey('priority', $urls[1]); - $this->assertSame('weekly', $urls[1]['changefreq']); - $this->assertSame(0.6, $urls[1]['priority']); - - // 상품 URL 구조 확인 (lastmod 있음) - $this->assertArrayHasKey('url', $urls[2]); - $this->assertArrayHasKey('lastmod', $urls[2]); - $this->assertArrayHasKey('changefreq', $urls[2]); - $this->assertArrayHasKey('priority', $urls[2]); - $this->assertSame('weekly', $urls[2]['changefreq']); - $this->assertSame(0.8, $urls[2]['priority']); - } -} diff --git a/tests/Unit/Seo/PageSitemapContributorTest.php b/tests/Unit/Seo/PageSitemapContributorTest.php deleted file mode 100644 index 6e4ea1bf..00000000 --- a/tests/Unit/Seo/PageSitemapContributorTest.php +++ /dev/null @@ -1,161 +0,0 @@ -contributor = new PageSitemapContributor; - } - - /** - * 테스트 종료 - Mockery 리소스를 정리합니다. - */ - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * SitemapContributorInterface를 구현하는지 확인합니다. - */ - public function test_implements_sitemap_contributor_interface(): void - { - $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); - } - - /** - * getIdentifier()가 'sirsoft-page'를 반환하는지 확인합니다. - */ - public function test_get_identifier_returns_correct_value(): void - { - $this->assertSame('sirsoft-page', $this->contributor->getIdentifier()); - } - - /** - * getUrls()가 발행된 페이지 URL을 반환하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_published_page_urls(): void - { - $pageCollection = collect([ - (object) ['slug' => 'about', 'updated_at' => now()], - (object) ['slug' => 'contact', 'updated_at' => now()], - (object) ['slug' => 'terms', 'updated_at' => now()], - ]); - - $pageQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $pageQuery->shouldReceive('get') - ->with(['slug', 'updated_at']) - ->andReturn($pageCollection); - - $pageMock = Mockery::mock('alias:'.Page::class); - $pageMock->shouldReceive('where') - ->with('published', true) - ->andReturn($pageQuery); - - $contributor = new PageSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertIsArray($urls); - $this->assertCount(3, $urls); - - // 각 페이지 URL 확인 - $this->assertSame('/page/about', $urls[0]['url']); - $this->assertSame('monthly', $urls[0]['changefreq']); - $this->assertSame(0.5, $urls[0]['priority']); - $this->assertArrayHasKey('lastmod', $urls[0]); - - $this->assertSame('/page/contact', $urls[1]['url']); - $this->assertSame('/page/terms', $urls[2]['url']); - } - - /** - * 발행된 페이지가 없을 때 빈 배열을 반환하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_empty_array_when_no_published_pages(): void - { - $pageQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $pageQuery->shouldReceive('get') - ->with(['slug', 'updated_at']) - ->andReturn(collect([])); - - $pageMock = Mockery::mock('alias:'.Page::class); - $pageMock->shouldReceive('where') - ->with('published', true) - ->andReturn($pageQuery); - - $contributor = new PageSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertIsArray($urls); - $this->assertEmpty($urls); - } - - /** - * URL 항목이 올바른 키 구조를 가지는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_url_entries_have_correct_structure(): void - { - $pageCollection = collect([ - (object) ['slug' => 'test-page', 'updated_at' => now()], - ]); - - $pageQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $pageQuery->shouldReceive('get') - ->with(['slug', 'updated_at']) - ->andReturn($pageCollection); - - $pageMock = Mockery::mock('alias:'.Page::class); - $pageMock->shouldReceive('where') - ->with('published', true) - ->andReturn($pageQuery); - - $contributor = new PageSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertCount(1, $urls); - - $entry = $urls[0]; - $this->assertArrayHasKey('url', $entry); - $this->assertArrayHasKey('lastmod', $entry); - $this->assertArrayHasKey('changefreq', $entry); - $this->assertArrayHasKey('priority', $entry); - - $this->assertSame('/page/test-page', $entry['url']); - $this->assertSame('monthly', $entry['changefreq']); - $this->assertSame(0.5, $entry['priority']); - } -} diff --git a/tests/Unit/Seo/SeoCacheSettingsTest.php b/tests/Unit/Seo/SeoCacheSettingsTest.php new file mode 100644 index 00000000..fa97db6e --- /dev/null +++ b/tests/Unit/Seo/SeoCacheSettingsTest.php @@ -0,0 +1,162 @@ +assertSame(7200, SeoCacheSettings::pageCacheTtl()); + } + + /** + * pageCacheTtl: SEO 탭에 값이 있으면 고급 탭을 오버라이드한다 (회귀 — 과거엔 무시됨) + */ + public function test_page_cache_ttl_seo_override_wins_over_advanced_tab(): void + { + Config::set('g7_settings.core.cache.seo_ttl', 7200); + Config::set('g7_settings.core.seo.cache_ttl', 3600); + + $this->assertSame( + 3600, + SeoCacheSettings::pageCacheTtl(), + 'SEO 탭에 지정한 값이 고급 탭보다 우선해야 합니다.' + ); + } + + /** + * pageCacheTtl: 양쪽 다 미설정이면 최종 기본값 + */ + public function test_page_cache_ttl_falls_back_to_default_when_both_unset(): void + { + $this->assertSame(7200, SeoCacheSettings::pageCacheTtl()); + } + + /** + * pageCacheEnabled: SEO 탭이 미설정이면 고급 탭 값을 따른다 + */ + public function test_page_cache_enabled_follows_advanced_tab_when_seo_is_unset(): void + { + Config::set('g7_settings.core.cache.seo_enabled', false); + Config::set('g7_settings.core.seo.cache_enabled', null); + + $this->assertFalse(SeoCacheSettings::pageCacheEnabled()); + } + + /** + * pageCacheEnabled: SEO 탭의 false 는 미설정이 아니라 오버라이드다 + * + * null 이 아닌 falsy 값(false)을 "미설정" 으로 잘못 판정하면, 운영자가 SEO 캐시를 + * 끄려 해도 고급 탭의 true 가 이겨 캐시가 계속 켜진다. + */ + public function test_page_cache_enabled_treats_false_as_explicit_override(): void + { + Config::set('g7_settings.core.cache.seo_enabled', true); + Config::set('g7_settings.core.seo.cache_enabled', false); + + $this->assertFalse( + SeoCacheSettings::pageCacheEnabled(), + 'false 는 미설정이 아니라 명시적 오버라이드여야 합니다.' + ); + } + + /** + * pageCacheEnabled: SEO 탭의 true 도 오버라이드로 동작한다 + */ + public function test_page_cache_enabled_true_overrides_disabled_advanced_tab(): void + { + Config::set('g7_settings.core.cache.seo_enabled', false); + Config::set('g7_settings.core.seo.cache_enabled', true); + + $this->assertTrue(SeoCacheSettings::pageCacheEnabled()); + } + + /** + * sitemapCacheTtl: SEO 탭이 미설정이면 고급 탭 값을 따른다 + */ + public function test_sitemap_cache_ttl_follows_advanced_tab_when_seo_is_unset(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 86400); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', null); + + $this->assertSame(86400, SeoCacheSettings::sitemapCacheTtl()); + } + + /** + * sitemapCacheTtl: SEO 탭에 값이 있으면 고급 탭을 오버라이드한다 (회귀) + */ + public function test_sitemap_cache_ttl_seo_override_wins_over_advanced_tab(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 86400); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', 3600); + + $this->assertSame(3600, SeoCacheSettings::sitemapCacheTtl()); + } + + /** + * sitemapCacheTtl: 양쪽 다 미설정이면 최종 기본값 + */ + public function test_sitemap_cache_ttl_falls_back_to_default_when_both_unset(): void + { + $this->assertSame(86400, SeoCacheSettings::sitemapCacheTtl()); + } + + /** + * 각 영역은 서로 독립이다 — SEO 페이지 캐시 지정이 sitemap 에 새지 않는다 + */ + public function test_domains_are_independent(): void + { + Config::set('g7_settings.core.cache.seo_ttl', 7200); + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 86400); + Config::set('g7_settings.core.seo.cache_ttl', 60); + + $this->assertSame(60, SeoCacheSettings::pageCacheTtl()); + $this->assertSame( + 86400, + SeoCacheSettings::sitemapCacheTtl(), + 'SEO 페이지 캐시 오버라이드가 sitemap 캐시에 영향을 주면 안 됩니다.' + ); + } + + /** + * 문자열로 저장된 값도 정수로 정규화된다 (폼 입력은 문자열로 올 수 있음) + */ + public function test_string_values_are_normalized_to_int(): void + { + Config::set('g7_settings.core.seo.sitemap_cache_ttl', '3600'); + + $this->assertSame(3600, SeoCacheSettings::sitemapCacheTtl()); + } +} diff --git a/tests/Unit/Seo/SeoCacheStatsServiceTest.php b/tests/Unit/Seo/SeoCacheStatsServiceTest.php index 829e4c08..4acfb753 100644 --- a/tests/Unit/Seo/SeoCacheStatsServiceTest.php +++ b/tests/Unit/Seo/SeoCacheStatsServiceTest.php @@ -23,7 +23,8 @@ class SeoCacheStatsServiceTest extends TestCase { parent::setUp(); - $this->statsService = new SeoCacheStatsService; + // Repository 주입이 필요하므로 컨테이너로 해석합니다. + $this->statsService = $this->app->make(SeoCacheStatsService::class); } /** diff --git a/tests/Unit/Seo/SitemapFileStoreTest.php b/tests/Unit/Seo/SitemapFileStoreTest.php new file mode 100644 index 00000000..5c5212be --- /dev/null +++ b/tests/Unit/Seo/SitemapFileStoreTest.php @@ -0,0 +1,277 @@ +storage = new CoreStorageDriver('local'); + $this->store = new SitemapFileStore($this->storage); + } + + /** + * 커밋된 sitemap 세트를 생성합니다. + * + * @param int $urlCount 생성할 URL 수 + * @param int $urlsPerFile 파일당 URL 수 + * @param bool $gzip gzip 압축 여부 + * @return array 커밋 결과 메타 + */ + private function commitSet(int $urlCount = 3, int $urlsPerFile = 10, bool $gzip = false): array + { + $writer = new SitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + $urlsPerFile, + $gzip, + ); + + $writer->open(); + for ($i = 1; $i <= $urlCount; $i++) { + // writer 는 절대 URL(loc) 을 받는다 — 상대 경로(url) 는 Generator 가 정규화하는 형식이다. + $writer->addUrl(['loc' => url("/page/{$i}")]); + } + $writer->close(); + + return $writer->commit(); + } + + /** + * StreamedResponse 본문을 문자열로 수집합니다. + * + * @param StreamedResponse $response 스트리밍 응답 + * @return string 응답 본문 + */ + private function streamBody(StreamedResponse $response): string + { + ob_start(); + $response->sendContent(); + + return (string) ob_get_clean(); + } + + /** + * childFilename: 자식 파일명 규칙이 고정된다 (경로 SSoT) + */ + public function test_child_filename_follows_naming_rule(): void + { + $this->assertSame('sitemap-1.xml', SitemapFileStore::childFilename(1)); + $this->assertSame('sitemap-7.xml', SitemapFileStore::childFilename(7)); + $this->assertSame('sitemap-1.xml.gz', SitemapFileStore::childFilename(1, true)); + } + + /** + * childUrl: 자식 sitemap 의 공개 절대 URL 을 반환한다 + */ + public function test_child_url_returns_absolute_url(): void + { + $this->assertSame(url('/sitemap-1.xml'), SitemapFileStore::childUrl(1)); + $this->assertSame(url('/sitemap-2.xml.gz'), SitemapFileStore::childUrl(2, true)); + } + + /** + * exists: 커밋된 세트가 없으면 false + */ + public function test_exists_returns_false_without_committed_set(): void + { + $this->assertFalse($this->store->exists()); + } + + /** + * exists: 커밋 후 true + */ + public function test_exists_returns_true_after_commit(): void + { + $this->commitSet(); + + $this->assertTrue($this->store->exists()); + } + + /** + * getManifest: 세트가 없으면 null + */ + public function test_get_manifest_returns_null_without_committed_set(): void + { + $this->assertNull($this->store->getManifest()); + } + + /** + * getManifest: 커밋된 메타를 반환한다 + */ + public function test_get_manifest_returns_committed_metadata(): void + { + $this->commitSet(urlCount: 3); + + $manifest = $this->store->getManifest(); + + $this->assertIsArray($manifest); + $this->assertSame(3, $manifest['url_count']); + $this->assertSame(1, $manifest['child_count']); + $this->assertCount(1, $manifest['children']); + $this->assertSame('sitemap-1.xml', $manifest['children'][0]['file']); + } + + /** + * getManifest: manifest 가 손상되면 null 을 반환한다 (예외 전파 금지) + */ + public function test_get_manifest_returns_null_when_manifest_is_corrupted(): void + { + $this->commitSet(); + $this->storage->put( + SitemapFileStore::CATEGORY, + SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE, + '{ this is not json', + ); + + $this->assertNull($this->store->getManifest()); + } + + /** + * indexResponse: 세트가 없으면 null + */ + public function test_index_response_returns_null_without_committed_set(): void + { + $this->assertNull($this->store->indexResponse()); + } + + /** + * indexResponse: sitemapindex 를 XML Content-Type 으로 스트리밍한다 + */ + public function test_index_response_streams_sitemap_index(): void + { + $this->commitSet(urlCount: 12, urlsPerFile: 10); + + $response = $this->store->indexResponse(); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertSame('application/xml', $response->headers->get('Content-Type')); + + $body = $this->streamBody($response); + $this->assertStringContainsString('assertStringContainsString(url('/sitemap-1.xml'), $body); + $this->assertStringContainsString(url('/sitemap-2.xml'), $body); + } + + /** + * childResponse: 세트가 없으면 null + */ + public function test_child_response_returns_null_without_committed_set(): void + { + $this->assertNull($this->store->childResponse(1)); + } + + /** + * childResponse: 자식 sitemap 본문을 스트리밍한다 + */ + public function test_child_response_streams_child_sitemap(): void + { + $this->commitSet(urlCount: 2); + + $response = $this->store->childResponse(1); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertSame('application/xml', $response->headers->get('Content-Type')); + $this->assertNull($response->headers->get('Content-Encoding')); + + $body = $this->streamBody($response); + $this->assertStringContainsString('assertStringContainsString(url('/page/1'), $body); + } + + /** + * childResponse: 존재하지 않는 자식 번호는 null + */ + public function test_child_response_returns_null_for_out_of_range_number(): void + { + $this->commitSet(urlCount: 2, urlsPerFile: 10); + + $this->assertNull($this->store->childResponse(2), '자식이 1개뿐이면 2번은 없어야 합니다.'); + } + + /** + * childResponse: 0 이하 번호는 null (경로 조작 방어) + */ + public function test_child_response_returns_null_for_non_positive_number(): void + { + $this->commitSet(); + + $this->assertNull($this->store->childResponse(0)); + $this->assertNull($this->store->childResponse(-1)); + } + + /** + * childResponse: gzip 세트는 Content-Encoding 헤더를 붙인다 + */ + public function test_child_response_sets_gzip_content_encoding(): void + { + $this->commitSet(urlCount: 2, urlsPerFile: 10, gzip: true); + + $response = $this->store->childResponse(1); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertSame('gzip', $response->headers->get('Content-Encoding')); + } + + /** + * indexResponse: gzip 세트여도 인덱스는 비압축이다 (D13) + */ + public function test_index_response_is_never_gzip_encoded(): void + { + $this->commitSet(urlCount: 2, urlsPerFile: 10, gzip: true); + + $response = $this->store->indexResponse(); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertNull($response->headers->get('Content-Encoding')); + } + + /** + * lastGeneratedAt: 세트가 없으면 null + */ + public function test_last_generated_at_returns_null_without_committed_set(): void + { + $this->assertNull($this->store->lastGeneratedAt()); + } + + /** + * lastGeneratedAt: manifest 의 생성 시각을 반환한다 + */ + public function test_last_generated_at_returns_manifest_timestamp(): void + { + $meta = $this->commitSet(); + + $this->assertNotNull($this->store->lastGeneratedAt()); + $this->assertSame($meta['generated_at'], $this->store->lastGeneratedAt()); + } +} diff --git a/tests/Unit/Seo/SitemapGeneratorTest.php b/tests/Unit/Seo/SitemapGeneratorTest.php index a729f91e..60082e70 100644 --- a/tests/Unit/Seo/SitemapGeneratorTest.php +++ b/tests/Unit/Seo/SitemapGeneratorTest.php @@ -2,9 +2,9 @@ namespace Tests\Unit\Seo; -use App\Extension\TemplateManager; use App\Seo\Contracts\SitemapContributorInterface; use App\Seo\SitemapGenerator; +use App\Seo\SitemapWriter; use App\Seo\TemplateRouteResolver; use App\Services\TemplateService; use Illuminate\Support\Facades\Config; @@ -475,6 +475,180 @@ class SitemapGeneratorTest extends TestCase $this->assertEquals(1, substr_count($xml, '')); } + // ─── renderUrlBlock / urlset 헤더·푸터 ────────────────── + + /** + * renderUrlBlock()이 단일 언어 블록을 생성하는지 확인합니다. + */ + public function test_render_url_block_single_locale(): void + { + $block = $this->generator->renderUrlBlock([ + 'loc' => 'https://example.test/a', + 'changefreq' => 'daily', + 'priority' => 0.8, + ]); + + $this->assertStringContainsString('https://example.test/a', $block); + $this->assertStringContainsString('daily', $block); + $this->assertStringContainsString('0.8', $block); + $this->assertStringNotContainsString('xhtml:link', $block); + $this->assertSame(1, substr_count($block, '')); + } + + /** + * renderUrlBlock()이 다국어에서 로케일별 블록과 hreflang을 생성하는지 확인합니다. + */ + public function test_render_url_block_multilingual(): void + { + config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]); + + $block = $this->generator->renderUrlBlock(['loc' => 'https://example.test/a']); + + $this->assertSame(2, substr_count($block, '')); + $this->assertStringContainsString('https://example.test/a', $block); + $this->assertStringContainsString('https://example.test/a?locale=en', $block); + $this->assertStringContainsString('hreflang="x-default"', $block); + } + + /** + * XML 특수문자가 이스케이프되는지 확인합니다. + */ + public function test_render_url_block_escapes_xml_special_characters(): void + { + $block = $this->generator->renderUrlBlock(['loc' => 'https://example.test/a?x=1&y=2']); + + $this->assertStringContainsString('https://example.test/a?x=1&y=2', $block); + // 이스케이프되지 않은 원본 & 가 남아 있으면 XML 파싱이 깨집니다. + $this->assertNotFalse(simplexml_load_string(''.$block.'')); + } + + /** + * urlset 헤더/푸터가 로케일 수에 따라 분기되는지 확인합니다. + */ + public function test_urlset_header_switches_on_multilingual(): void + { + $this->assertStringNotContainsString('xmlns:xhtml', $this->generator->buildUrlsetHeader()); + $this->assertSame('', $this->generator->buildUrlsetFooter()); + + config(['app.supported_locales' => ['ko', 'en']]); + + $this->assertStringContainsString('xmlns:xhtml', $this->generator->buildUrlsetHeader()); + } + + // ─── generateToWriter ──────────────────────────────── + + /** + * generateToWriter()가 기여자 URL을 writer 에 한 건씩 전달하고 + * 상대 url 을 절대 loc 으로 변환하는지 확인합니다. + */ + public function test_generate_to_writer_streams_entries_without_cross_merge(): void + { + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $contributorA = Mockery::mock(SitemapContributorInterface::class); + $contributorA->shouldReceive('getIdentifier')->andReturn('mod-a'); + $contributorA->shouldReceive('getUrls')->andReturn([ + ['url' => '/a/1'], + ['url' => '/a/2'], + ]); + + $contributorB = Mockery::mock(SitemapContributorInterface::class); + $contributorB->shouldReceive('getIdentifier')->andReturn('mod-b'); + $contributorB->shouldReceive('getUrls')->andReturn([ + ['url' => '/b/1'], + ]); + + $this->generator->registerContributor($contributorA); + $this->generator->registerContributor($contributorB); + + $writer = new SpySitemapWriter; + $meta = $this->generator->generateToWriter($writer); + + $this->assertTrue($writer->opened); + $this->assertTrue($writer->closed); + $this->assertTrue($writer->committed); + $this->assertSame(['committed' => true], $meta); + + $locs = array_column($writer->entries, 'loc'); + $this->assertContains(url('/a/1'), $locs); + $this->assertContains(url('/a/2'), $locs); + $this->assertContains(url('/b/1'), $locs); + + // 'url' 키는 절대 loc 으로 변환되어 남지 않아야 합니다. + foreach ($writer->entries as $entry) { + $this->assertArrayNotHasKey('url', $entry); + $this->assertArrayHasKey('loc', $entry); + } + } + + /** + * 기여자를 하나씩 소진하여 writer 로 흘려보내는지 확인합니다 (전체 병합 부재 증명). + * + * 모든 기여자의 URL 을 배열로 모은 뒤 한 번에 쓰는 구현이라면, 두 번째 기여자의 + * getUrls() 가 호출되는 시점에 writer 는 아직 비어 있다. 이 테스트는 그 구현을 걸러낸다. + */ + public function test_generate_to_writer_drains_each_contributor_before_calling_the_next(): void + { + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $writer = new SpySitemapWriter; + + $contributorA = Mockery::mock(SitemapContributorInterface::class); + $contributorA->shouldReceive('getIdentifier')->andReturn('mod-a'); + $contributorA->shouldReceive('getUrls')->andReturn([['url' => '/a/1']]); + + $locsSeenWhenBWasAsked = null; + + $contributorB = Mockery::mock(SitemapContributorInterface::class); + $contributorB->shouldReceive('getIdentifier')->andReturn('mod-b'); + $contributorB->shouldReceive('getUrls')->andReturnUsing( + function () use ($writer, &$locsSeenWhenBWasAsked): array { + $locsSeenWhenBWasAsked = array_column($writer->entries, 'loc'); + + return [['url' => '/b/1']]; + } + ); + + $this->generator->registerContributor($contributorA); + $this->generator->registerContributor($contributorB); + $this->generator->generateToWriter($writer); + + $this->assertNotNull($locsSeenWhenBWasAsked, 'B 기여자가 호출되어야 합니다.'); + $this->assertContains( + url('/a/1'), + $locsSeenWhenBWasAsked, + 'B 를 호출하기 전에 A 의 URL 이 이미 writer 로 전달되어야 합니다 (전체 병합 금지).' + ); + } + + /** + * 한 기여자가 실패해도 다른 기여자의 URL 이 계속 기록되는지 확인합니다. + */ + public function test_generate_to_writer_isolates_contributor_failure(): void + { + $failing = Mockery::mock(SitemapContributorInterface::class); + $failing->shouldReceive('getIdentifier')->andReturn('failing-module'); + $failing->shouldReceive('getUrls')->andThrow(new \RuntimeException('DB connection failed')); + + $working = Mockery::mock(SitemapContributorInterface::class); + $working->shouldReceive('getIdentifier')->andReturn('working-module'); + $working->shouldReceive('getUrls')->andReturn([['url' => '/ok']]); + + Log::shouldReceive('warning') + ->once() + ->withArgs(fn (string $message, array $context) => str_contains($message, 'Sitemap contributor failed') + && $context['contributor'] === 'failing-module'); + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $this->generator->registerContributor($failing); + $this->generator->registerContributor($working); + + $writer = new SpySitemapWriter; + $this->generator->generateToWriter($writer); + + $this->assertContains(url('/ok'), array_column($writer->entries, 'loc')); + } + // ─── 헬퍼 메서드 ────────────────────────────────────── /** @@ -542,3 +716,76 @@ class SitemapGeneratorTest extends TestCase return $urls; } } + +/** + * SitemapWriter 호출을 기록하는 테스트 더블. + * + * generateToWriter 가 URL 을 한 건씩 스트리밍하는지(전체 배열 병합 없음)와 + * open/close/commit 생명주기를 검증하기 위해 사용합니다. + * 스토리지 없이 동작하도록 부모 생성자를 호출하지 않습니다. + */ +class SpySitemapWriter extends SitemapWriter +{ + /** + * open() 호출 여부 + */ + public bool $opened = false; + + /** + * close() 호출 여부 + */ + public bool $closed = false; + + /** + * commit() 호출 여부 + */ + public bool $committed = false; + + /** + * addUrl() 로 전달된 URL 항목 목록 + * + * @var array> + */ + public array $entries = []; + + /** + * SpySitemapWriter 생성자 (의존성 없이 생성). + */ + public function __construct() {} + + /** + * {@inheritDoc} + */ + public function open(): void + { + $this->opened = true; + } + + /** + * {@inheritDoc} + */ + public function addUrl(array $entry): void + { + $this->entries[] = $entry; + } + + /** + * {@inheritDoc} + */ + public function close(): array + { + $this->closed = true; + + return []; + } + + /** + * {@inheritDoc} + */ + public function commit(): array + { + $this->committed = true; + + return ['committed' => true]; + } +} diff --git a/tests/Unit/Seo/SitemapManagerTest.php b/tests/Unit/Seo/SitemapManagerTest.php new file mode 100644 index 00000000..952e6168 --- /dev/null +++ b/tests/Unit/Seo/SitemapManagerTest.php @@ -0,0 +1,384 @@ +storage = new CoreStorageDriver('local'); + + $this->clearSitemapHooks(); + } + + /** + * 테스트 정리 - 등록한 훅 리스너가 다른 테스트로 누출되지 않도록 제거합니다. + */ + protected function tearDown(): void + { + $this->clearSitemapHooks(); + + parent::tearDown(); + } + + /** + * sitemap 훅 리스너를 모두 제거합니다. + */ + private function clearSitemapHooks(): void + { + foreach (self::SITEMAP_HOOKS as $hook) { + HookManager::clearAction($hook); + } + } + + /** + * 지정한 URL 을 내보내는 가짜 contributor 를 만듭니다. + * + * @param array $urls 상대 경로 목록 + * @return SitemapContributorInterface 가짜 기여자 + */ + private function fakeContributor(array $urls): SitemapContributorInterface + { + return new class($urls) implements SitemapContributorInterface + { + /** + * @param array $urls 상대 경로 목록 + */ + public function __construct(private array $urls) {} + + public function getIdentifier(): string + { + return 'fake-contributor'; + } + + public function getUrls(): array + { + return array_map(fn (string $url): array => ['url' => $url], $this->urls); + } + }; + } + + /** + * 테스트용 SitemapManager 를 만듭니다. + * + * @param array $urls 기여자가 내보낼 URL 목록 + * @param CacheInterface|null $cache 캐시 (미지정 시 기대 없는 spy) + * @param ConfigRepositoryInterface|null $config 설정 Repository + * @return SitemapManager manager 인스턴스 + */ + private function makeManager( + array $urls = ['/a'], + ?CacheInterface $cache = null, + ?ConfigRepositoryInterface $config = null, + ): SitemapManager { + // generator 는 라우트 해석기/템플릿 서비스에 의존하므로 컨테이너로 해석합니다. + $generator = $this->app->make(SitemapGenerator::class); + $generator->registerContributor($this->fakeContributor($urls)); + + return new SitemapManager( + $generator, + $cache ?? Mockery::spy(CacheInterface::class), + $config ?? Mockery::spy(ConfigRepositoryInterface::class), + $this->storage, + ); + } + + /** + * regenerate: sitemap 이 비활성화되면 생성하지 않고 disabled 를 반환한다 + */ + public function test_regenerate_returns_disabled_when_sitemap_is_disabled(): void + { + Config::set('g7_settings.core.seo.sitemap_enabled', false); + + $fired = []; + HookManager::addAction('core.seo.sitemap.before_regenerate', function () use (&$fired) { + $fired[] = 'before'; + }); + + $result = $this->makeManager()->regenerate(); + + $this->assertFalse($result['success']); + $this->assertSame('disabled', $result['status']); + $this->assertArrayNotHasKey('data', $result); + $this->assertSame([], $fired, '비활성화 시 훅이 발화하면 안 됩니다.'); + $this->assertFalse( + $this->storage->exists(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE), + '비활성화 시 파일을 커밋하면 안 됩니다.' + ); + } + + /** + * regenerate: 성공 시 status=updated 와 메타 5키를 반환한다 + */ + public function test_regenerate_returns_updated_with_meta_payload(): void + { + $result = $this->makeManager(['/a', '/b'])->regenerate(); + + $this->assertTrue($result['success']); + $this->assertSame('updated', $result['status']); + $this->assertSame( + ['last_updated_at', 'size_bytes', 'url_count', 'child_count', 'ttl'], + array_keys($result['data']), + 'S2 Controller 가 소비하는 메타 키 집합이 고정되어야 합니다.' + ); + $this->assertSame(1, $result['data']['child_count']); + $this->assertGreaterThan(0, $result['data']['size_bytes']); + } + + /** + * regenerate: 성공 시 sitemap 세트가 디스크에 커밋된다 + */ + public function test_regenerate_commits_sitemap_set_to_disk(): void + { + $this->makeManager(['/a'])->regenerate(); + + $store = new SitemapFileStore($this->storage); + + $this->assertTrue($store->exists()); + $this->assertNotNull($store->indexResponse()); + $this->assertNotNull($store->childResponse(1)); + } + + /** + * regenerate: 구 XML 본문 캐시를 정리하고 메타만 캐시에 저장한다 + */ + public function test_regenerate_forgets_legacy_xml_cache_and_puts_meta(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 3600); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', null); + + $cache = Mockery::mock(CacheInterface::class); + $cache->shouldReceive('forget')->once()->with('seo.sitemap'); + $cache->shouldReceive('put') + ->once() + ->with(SitemapManager::META_CACHE_KEY, Mockery::type('array'), 3600); + + $this->makeManager(['/a'], $cache)->regenerate(); + } + + /** + * regenerate: SEO 탭 지정이 없으면 고급 탭(cache.seo_sitemap_ttl) 값을 ttl 로 쓴다 (D19) + */ + public function test_regenerate_uses_advanced_tab_ttl_when_seo_override_is_unset(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 7200); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', null); + + $result = $this->makeManager()->regenerate(); + + $this->assertSame(7200, $result['data']['ttl']); + } + + /** + * regenerate: SEO 탭에 지정이 있으면 그 값이 고급 탭을 오버라이드한다 (D19) + */ + public function test_regenerate_uses_seo_override_ttl_when_set(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 7200); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', 3600); + + $result = $this->makeManager()->regenerate(); + + $this->assertSame(3600, $result['data']['ttl'], 'SEO 탭 지정이 고급 탭보다 우선해야 합니다.'); + } + + /** + * regenerate: last_updated_at 을 seo 설정 카테고리에 기록한다 + */ + public function test_regenerate_records_last_updated_at_in_settings(): void + { + $config = Mockery::mock(ConfigRepositoryInterface::class); + $config->shouldReceive('getCategory')->once()->with('seo')->andReturn(['existing' => 'keep']); + $config->shouldReceive('saveCategory') + ->once() + ->with('seo', Mockery::on(function (array $saved): bool { + // 기존 키를 보존하면서 타임스탬프만 추가해야 한다 + return $saved['existing'] === 'keep' + && ! empty($saved['sitemap_last_updated_at']); + })); + + $result = $this->makeManager(['/a'], null, $config)->regenerate(); + + $this->assertTrue($result['success']); + } + + /** + * regenerate: 설정 기록이 실패해도 생성 자체는 성공으로 유지된다 + */ + public function test_regenerate_survives_settings_write_failure(): void + { + $config = Mockery::mock(ConfigRepositoryInterface::class); + $config->shouldReceive('getCategory')->andThrow(new \RuntimeException('설정 저장소 장애')); + + $result = $this->makeManager(['/a'], null, $config)->regenerate(); + + $this->assertTrue($result['success'], 'last_updated_at 기록 실패가 sitemap 생성을 무효화하면 안 됩니다.'); + $this->assertSame('updated', $result['status']); + } + + /** + * regenerate: 성공 시 before/after_regenerate 훅이 순서대로 발화한다 + */ + public function test_regenerate_fires_before_and_after_hooks_in_order(): void + { + $order = []; + HookManager::addAction('core.seo.sitemap.before_regenerate', function () use (&$order) { + $order[] = 'before'; + }); + HookManager::addAction('core.seo.sitemap.after_regenerate', function () use (&$order) { + $order[] = 'after'; + }); + + $this->makeManager()->regenerate(); + + $this->assertSame(['before', 'after'], $order); + } + + /** + * regenerate: 실패 시 failed 를 반환하고 after_regenerate_failed 훅이 발화한다 + * + * 이 훅은 확장이 구독하는 공개 표면이므로 발화 여부와 페이로드 형태를 고정한다. + */ + public function test_regenerate_fires_after_regenerate_failed_hook_on_failure(): void + { + $payloads = []; + HookManager::addAction('core.seo.sitemap.after_regenerate_failed', function ($result) use (&$payloads) { + $payloads[] = $result; + }); + HookManager::addAction('core.seo.sitemap.after_regenerate', function () { + throw new \LogicException('성공 훅이 발화하면 안 됩니다.'); + }); + + // 생성 도중 예외를 던지는 generator 로 실패를 강제한다. + $generator = Mockery::mock(SitemapGenerator::class); + $generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('디스크 장애')); + + $manager = new SitemapManager( + $generator, + Mockery::spy(CacheInterface::class), + Mockery::spy(ConfigRepositoryInterface::class), + $this->storage, + ); + + $result = $manager->regenerate(); + + $this->assertFalse($result['success']); + $this->assertSame('failed', $result['status']); + $this->assertStringContainsString('디스크 장애', $result['message']); + + $this->assertCount(1, $payloads, 'after_regenerate_failed 훅이 정확히 1회 발화해야 합니다.'); + $this->assertSame('failed', $payloads[0]['status']); + $this->assertFalse($payloads[0]['success']); + } + + /** + * regenerate: 실패 시 캐시를 갱신하지 않는다 (구 세트 유지) + */ + public function test_regenerate_does_not_touch_cache_on_failure(): void + { + $cache = Mockery::mock(CacheInterface::class); + $cache->shouldNotReceive('put'); + $cache->shouldNotReceive('forget'); + + $generator = Mockery::mock(SitemapGenerator::class); + $generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('장애')); + + $manager = new SitemapManager( + $generator, + $cache, + Mockery::spy(ConfigRepositoryInterface::class), + $this->storage, + ); + + $this->assertFalse($manager->regenerate()['success']); + } + + /** + * regenerate: seo.sitemap_urls_per_file 설정이 자식 분할에 반영된다 + */ + public function test_regenerate_applies_urls_per_file_setting(): void + { + Config::set('g7_settings.core.seo.sitemap_urls_per_file', 2); + + // 정적 라우트 + 기여자 4건 → 파일당 2건이면 자식이 2개 이상으로 나뉜다 + $result = $this->makeManager(['/a', '/b', '/c', '/d'])->regenerate(); + + $this->assertGreaterThan(1, $result['data']['child_count'], 'urls_per_file 설정이 분할에 반영돼야 합니다.'); + } + + /** + * regenerate: seo.sitemap_gzip 설정이 커밋 파일명에 반영된다 + */ + public function test_regenerate_applies_gzip_setting(): void + { + Config::set('g7_settings.core.seo.sitemap_gzip', true); + + $this->makeManager(['/a'])->regenerate(); + + $manifest = (new SitemapFileStore($this->storage))->getManifest(); + + $this->assertStringEndsWith('.xml.gz', $manifest['children'][0]['file']); + } + + /** + * getStatus: 기록된 last_updated_at 을 반환하고, 미기록 시 null 을 반환한다 + */ + public function test_get_status_returns_last_updated_at_or_null(): void + { + $manager = $this->makeManager(); + + Config::set('g7_settings.core.seo.sitemap_last_updated_at', ''); + $this->assertNull($manager->getStatus()['last_updated_at']); + + Config::set('g7_settings.core.seo.sitemap_last_updated_at', '2026-07-16T00:00:00+09:00'); + $this->assertSame('2026-07-16T00:00:00+09:00', $manager->getStatus()['last_updated_at']); + } +} diff --git a/tests/Unit/Seo/SitemapWriterTest.php b/tests/Unit/Seo/SitemapWriterTest.php new file mode 100644 index 00000000..1bbe3f02 --- /dev/null +++ b/tests/Unit/Seo/SitemapWriterTest.php @@ -0,0 +1,639 @@ +storage = new RecordingStorage(new CoreStorageDriver('local')); + } + + /** + * 테스트용 writer 를 생성합니다. + * + * @param int $urlsPerFile 파일당 URL 수 + * @param bool $gzip gzip 압축 여부 + * @return SitemapWriter writer 인스턴스 + */ + private function makeWriter(int $urlsPerFile = 10, bool $gzip = false): SitemapWriter + { + return new SitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + $urlsPerFile, + $gzip, + ); + } + + /** + * 커밋된 live 파일 내용을 읽습니다. + * + * @param string $filename 파일명 + * @return string|null 파일 내용 + */ + private function liveContent(string $filename): ?string + { + return $this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.$filename); + } + + // ─── 분할 ──────────────────────────────────────────── + + /** + * URL 수가 임계를 넘으면 자식 파일로 분할되는지 확인합니다. + */ + public function test_splits_children_at_threshold(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + // 25건 / 파일당 10건 → 자식 3개 (10, 10, 5) + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}"), 'priority' => 0.5]); + } + + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(3, $meta['child_count']); + $this->assertSame(25, $meta['url_count']); + $this->assertSame(10, $meta['children'][0]['url_count']); + $this->assertSame(10, $meta['children'][1]['url_count']); + $this->assertSame(5, $meta['children'][2]['url_count']); + } + + /** + * 임계에 정확히 맞아떨어지면 빈 자식 파일이 생기지 않는지 확인합니다. + */ + public function test_exact_threshold_does_not_create_empty_child(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + for ($i = 1; $i <= 20; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(2, $meta['child_count']); + } + + /** + * 각 자식 파일이 well-formed urlset XML 인지 확인합니다. + */ + public function test_children_are_well_formed_urlsets(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}"), 'changefreq' => 'daily']); + } + + $writer->close(); + $meta = $writer->commit(); + + foreach ($meta['children'] as $child) { + $xml = $this->liveContent($child['file']); + + $this->assertNotNull($xml); + $this->assertStringStartsWith('', $xml); + $this->assertStringEndsWith('', $xml); + $this->assertLessThanOrEqual(SitemapWriter::HARD_URL_CAP, substr_count($xml, '')); + $this->assertNotFalse(simplexml_load_string($xml), '자식 sitemap 이 유효한 XML 이어야 합니다.'); + } + } + + /** + * sitemapindex 가 모든 자식을 절대 URL 로 참조하는지 확인합니다. + */ + public function test_index_references_all_children(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $writer->commit(); + + $index = $this->liveContent(SitemapFileStore::INDEX_FILE); + + $this->assertNotNull($index); + $this->assertStringContainsString('assertSame(3, substr_count($index, '')); + $this->assertStringContainsString(''.url('/sitemap-1.xml').'', $index); + $this->assertStringContainsString(''.url('/sitemap-3.xml').'', $index); + $this->assertNotFalse(simplexml_load_string($index)); + } + + /** + * URL 이 하나도 없어도 빈 urlset 자식 1개와 인덱스를 남기는지 확인합니다. + */ + public function test_empty_set_still_emits_index_and_one_child(): void + { + $writer = $this->makeWriter(); + $writer->open(); + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(1, $meta['child_count']); + $this->assertSame(0, $meta['url_count']); + + $xml = $this->liveContent('sitemap-1.xml'); + $this->assertStringNotContainsString('', $xml); + $this->assertNotFalse(simplexml_load_string($xml)); + } + + /** + * loc 이 없는 항목은 무시되는지 확인합니다. + */ + public function test_entry_without_loc_is_skipped(): void + { + $writer = $this->makeWriter(); + $writer->open(); + $writer->addUrl(['changefreq' => 'daily']); + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(0, $meta['url_count']); + } + + // ─── 다국어 ────────────────────────────────────────── + + /** + * 다국어에서는 URL 하나가 로케일 수만큼의 블록으로 계산되는지 확인합니다. + */ + public function test_multilingual_counts_url_blocks_per_locale(): void + { + Config::set('app.supported_locales', ['ko', 'en']); + + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + // 6건 × 2로케일 = 12블록 → 자식 2개 + for ($i = 1; $i <= 6; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(12, $meta['url_count']); + $this->assertSame(2, $meta['child_count']); + } + + // ─── gzip ──────────────────────────────────────────── + + /** + * gzip 활성 시 .xml.gz 로 압축 저장되고 인덱스 loc 도 .gz 를 가리키는지 확인합니다. + */ + public function test_gzip_writes_compressed_children(): void + { + $writer = $this->makeWriter(urlsPerFile: 10, gzip: true); + $writer->open(); + + for ($i = 1; $i <= 5; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $meta = $writer->commit(); + + $this->assertTrue($meta['gzip']); + $this->assertSame('sitemap-1.xml.gz', $meta['children'][0]['file']); + + $raw = $this->liveContent('sitemap-1.xml.gz'); + $decoded = gzdecode($raw); + + $this->assertNotFalse($decoded, 'gzip 자식 파일이 해제 가능해야 합니다.'); + $this->assertStringEndsWith('', $decoded); + + // 인덱스는 비압축이며 .gz 자식을 가리킵니다. + $index = $this->liveContent(SitemapFileStore::INDEX_FILE); + $this->assertStringContainsString(''.url('/sitemap-1.xml.gz').'', $index); + } + + /** + * gzip 비활성이 기본값인지 확인합니다. + */ + public function test_gzip_disabled_by_default(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + $writer->addUrl(['loc' => url('/p/1')]); + $writer->close(); + $meta = $writer->commit(); + + $this->assertFalse($meta['gzip']); + $this->assertSame('sitemap-1.xml', $meta['children'][0]['file']); + } + + // ─── 커밋 ──────────────────────────────────────────── + + /** + * manifest.json 이 커밋의 마지막 기록인지 확인합니다 (커밋 마커). + */ + public function test_manifest_is_written_last(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $writer->commit(); + + $manifestPath = SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE; + + $this->assertSame($manifestPath, end($this->storage->putOrder)); + + // live 자식/인덱스가 모두 manifest 보다 먼저 기록되었는지 확인 + $manifestIndex = array_search($manifestPath, $this->storage->putOrder, true); + foreach (['sitemap-1.xml', 'sitemap-2.xml', 'sitemap-3.xml', SitemapFileStore::INDEX_FILE] as $file) { + $position = array_search(SitemapFileStore::LIVE_DIR.'/'.$file, $this->storage->putOrder, true); + + $this->assertNotFalse($position, "{$file} 이 live 에 기록되어야 합니다."); + $this->assertLessThan($manifestIndex, $position); + } + } + + /** + * 커밋 후 임시 디렉토리가 정리되는지 확인합니다. + */ + public function test_commit_cleans_tmp_directory(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + $writer->addUrl(['loc' => url('/p/1')]); + $writer->close(); + $writer->commit(); + + $this->assertSame([], $this->storage->files(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR)); + } + + /** + * 이전 생성물 중 새 세트에 없는 자식 파일이 정리되는지 확인합니다. + */ + public function test_commit_removes_stale_children(): void + { + // 1차: 자식 3개 + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + $writer->close(); + $writer->commit(); + + $this->assertNotNull($this->liveContent('sitemap-3.xml')); + + // 2차: 자식 1개로 축소 → sitemap-2/3 은 stale + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + $writer->addUrl(['loc' => url('/p/1')]); + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(1, $meta['child_count']); + $this->assertNotNull($this->liveContent('sitemap-1.xml')); + $this->assertNull($this->liveContent('sitemap-2.xml')); + $this->assertNull($this->liveContent('sitemap-3.xml')); + } + + /** + * open() 이 이전 실행의 임시 파일을 정리하는지 확인합니다. + */ + public function test_open_clears_previous_tmp_files(): void + { + $this->storage->put(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR.'/sitemap-9.xml', 'stale'); + + $writer = $this->makeWriter(); + $writer->open(); + + $this->assertNull($this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR.'/sitemap-9.xml')); + } + + /** + * manifest 에 세트 메타데이터가 기록되는지 확인합니다. + */ + public function test_manifest_contains_set_metadata(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + for ($i = 1; $i <= 15; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + $writer->close(); + $writer->commit(); + + $manifest = json_decode($this->liveContent(SitemapFileStore::MANIFEST_FILE), true); + + $this->assertSame(2, $manifest['child_count']); + $this->assertSame(15, $manifest['url_count']); + $this->assertSame(10, $manifest['urls_per_file']); + $this->assertNotEmpty($manifest['generated_at']); + $this->assertCount(2, $manifest['children']); + } + + // ─── 상한 / 메모리 ──────────────────────────────────── + + /** + * urlsPerFile 이 프로토콜 상한(50,000)을 넘지 못하도록 클램프되는지 확인합니다. + */ + public function test_urls_per_file_is_clamped_to_protocol_cap(): void + { + $writer = $this->makeWriter(urlsPerFile: 999999); + $writer->open(); + $writer->addUrl(['loc' => url('/p/1')]); + $writer->close(); + $writer->commit(); + + $manifest = json_decode($this->liveContent(SitemapFileStore::MANIFEST_FILE), true); + + $this->assertSame(SitemapWriter::HARD_URL_CAP, $manifest['urls_per_file']); + } + + /** + * 대용량(120,001건) 생성 시 자식 수가 정확하고 메모리가 유계인지 확인합니다. + * + * 전체 in-memory 적재(회귀)라면 URL 수에 비례해 메모리가 증가합니다. + */ + public function test_large_volume_splits_and_keeps_memory_bounded(): void + { + $writer = $this->makeWriter(urlsPerFile: 50000); + + // peak 는 프로세스 전체의 high-water mark 라, 선행 테스트가 올려둔 peak 때문에 + // 델타가 0 으로 나와 거짓 통과할 수 있다. 이 테스트 구간만 측정하도록 리셋한다. + memory_reset_peak_usage(); + $before = memory_get_peak_usage(true); + + $writer->open(); + for ($i = 1; $i <= 120001; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}"), 'priority' => 0.5]); + } + $writer->close(); + $meta = $writer->commit(); + + $growth = memory_get_peak_usage(true) - $before; + + // 120001 = 50000 + 50000 + 20001 + $this->assertSame(3, $meta['child_count']); + $this->assertSame(120001, $meta['url_count']); + $this->assertSame(50000, $meta['children'][0]['url_count']); + $this->assertSame(50000, $meta['children'][1]['url_count']); + $this->assertSame(20001, $meta['children'][2]['url_count']); + + // 자식 파일 1개 버퍼(수 MB) 수준을 넘지 않아야 합니다. + // 실측 약 10MB (자식 파일 1개 버퍼 수준). 전체 적재 회귀 시 수십~수백 MB 로 튄다. + $this->assertLessThan( + 64 * 1024 * 1024, + $growth, + '메모리 증가가 자식 파일 1개 버퍼 수준을 넘었습니다 (전체 적재 회귀 의심): '.$growth, + ); + } + + /** + * URL 수가 상한에 못 미쳐도 바이트 임계에 도달하면 자식이 분할됩니다. + * + * sitemaps.org 는 파일당 50MB 를 제한하므로 URL 수 상한만으로는 프로토콜 위반을 막지 못한다 + * (긴 URL 5만 건이면 50MB 초과). MAX_FILE_BYTES 분기가 그 방어인데, 45MB 를 실제로 채우면 + * 테스트가 과도하게 무거우므로 하위 클래스로 임계값만 낮춰 같은 경로를 검증한다. + */ + public function test_byte_threshold_splits_child_even_below_url_cap(): void + { + $writer = new SmallByteCapSitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + 10000, // URL 수 상한 — 이번 케이스에서는 절대 도달하지 않는다 + false, + ); + + $writer->open(); + // URL 블록 1개가 임계(2KB)를 넘도록 긴 loc 를 쓴다 → 매 건마다 flush + for ($i = 1; $i <= 3; $i++) { + $writer->addUrl(['loc' => url('/p/'.str_repeat("long-segment-{$i}-", 200))]); + } + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(3, $meta['url_count']); + $this->assertSame( + 3, + $meta['child_count'], + 'URL 수 상한(10000) 미달이어도 바이트 임계 도달 시 자식이 분할돼야 합니다.' + ); + } + + /** + * 바이트 임계로 분할된 자식들도 well-formed urlset 이며 전량 보존됩니다. + */ + public function test_byte_threshold_split_children_are_well_formed_and_lossless(): void + { + $writer = new SmallByteCapSitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + 10000, + false, + ); + + $writer->open(); + $locs = []; + for ($i = 1; $i <= 3; $i++) { + $loc = url('/p/'.str_repeat("seg-{$i}-", 200)); + $locs[] = $loc; + $writer->addUrl(['loc' => $loc]); + } + $writer->close(); + $meta = $writer->commit(); + + $combined = ''; + foreach ($meta['children'] as $child) { + $xml = $this->liveContent($child['file']); + + $this->assertStringStartsWith('', $xml); + $this->assertStringContainsString('', $xml); + $combined .= $xml; + } + + // 분할 과정에서 URL 이 유실되면 안 된다 + foreach ($locs as $loc) { + $this->assertStringContainsString($loc, $combined); + } + } +} + +/** + * 바이트 임계를 2KB 로 낮춘 writer (테스트 전용). + * + * MAX_FILE_BYTES 분기는 static:: 바인딩이므로 상수 재정의만으로 임계값을 바꿀 수 있다. + */ +class SmallByteCapSitemapWriter extends SitemapWriter +{ + public const MAX_FILE_BYTES = 2048; +} + +/** + * put() 호출 경로를 순서대로 기록하는 StorageInterface 데코레이터 (테스트 전용). + * + * manifest.json 이 커밋의 마지막 기록인지 검증하기 위해 사용합니다. + */ +class RecordingStorage implements StorageInterface +{ + /** + * put 이 호출된 경로 목록 (호출 순서) + * + * @var array + */ + public array $putOrder = []; + + /** + * RecordingStorage 생성자 + * + * @param StorageInterface $inner 위임 대상 스토리지 + */ + public function __construct(private StorageInterface $inner) {} + + /** + * {@inheritDoc} + */ + public function put(string $category, string $path, mixed $content): bool + { + $this->putOrder[] = $path; + + return $this->inner->put($category, $path, $content); + } + + /** + * {@inheritDoc} + */ + public function get(string $category, string $path): ?string + { + return $this->inner->get($category, $path); + } + + /** + * {@inheritDoc} + */ + public function exists(string $category, string $path): bool + { + return $this->inner->exists($category, $path); + } + + /** + * {@inheritDoc} + */ + public function delete(string $category, string $path): bool + { + return $this->inner->delete($category, $path); + } + + /** + * {@inheritDoc} + */ + public function url(string $category, string $path): ?string + { + return $this->inner->url($category, $path); + } + + /** + * {@inheritDoc} + */ + public function files(string $category, string $directory = ''): array + { + return $this->inner->files($category, $directory); + } + + /** + * {@inheritDoc} + */ + public function deleteDirectory(string $category, string $directory = ''): bool + { + return $this->inner->deleteDirectory($category, $directory); + } + + /** + * {@inheritDoc} + */ + public function getBasePath(string $category): string + { + return $this->inner->getBasePath($category); + } + + /** + * {@inheritDoc} + */ + public function getDisk(): string + { + return $this->inner->getDisk(); + } + + /** + * {@inheritDoc} + */ + public function deleteAll(string $category): bool + { + return $this->inner->deleteAll($category); + } + + /** + * {@inheritDoc} + */ + public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse + { + return $this->inner->response($category, $path, $filename, $headers); + } + + /** + * {@inheritDoc} + */ + public function withDisk(string $disk): static + { + $clone = clone $this; + $clone->inner = $this->inner->withDisk($disk); + + return $clone; + } + + /** + * {@inheritDoc} + */ + public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse + { + return $this->inner->download($category, $path, $filename, $headers); + } +} diff --git a/tests/Unit/Seo/SitemapXmlRendererTest.php b/tests/Unit/Seo/SitemapXmlRendererTest.php new file mode 100644 index 00000000..0c613c3c --- /dev/null +++ b/tests/Unit/Seo/SitemapXmlRendererTest.php @@ -0,0 +1,337 @@ + 블록 수) + * - hreflang alternate 구조 (현재: 로케일 수 × (로케일 수 + x-default)) + * - 로케일 URL 규칙 (기본 로케일은 원본, 그 외 ?locale=) + * - sitemapindex 구조 + */ +class SitemapXmlRendererTest extends TestCase +{ + /** + * 단일 언어 렌더러를 만듭니다. + * + * @return SitemapXmlRenderer 렌더러 + */ + private function singleLocale(): SitemapXmlRenderer + { + return new SitemapXmlRenderer(['ko'], 'ko'); + } + + /** + * 다국어 렌더러를 만듭니다. + * + * @param array $locales 지원 로케일 + * @return SitemapXmlRenderer 렌더러 + */ + private function multiLocale(array $locales = ['ko', 'en', 'ja']): SitemapXmlRenderer + { + return new SitemapXmlRenderer($locales, 'ko'); + } + + /** + * fromConfig: 앱 로케일 설정을 반영한다 + */ + public function test_from_config_reflects_app_locale_settings(): void + { + Config::set('app.locale', 'ko'); + Config::set('app.supported_locales', ['ko', 'en']); + + $renderer = SitemapXmlRenderer::fromConfig(); + + $this->assertTrue($renderer->isMultilingual()); + $this->assertSame(2, $renderer->urlBlockCount()); + } + + /** + * fromConfig: supported_locales 미설정 시 기본 로케일 단독으로 동작한다 + */ + public function test_from_config_falls_back_to_default_locale_only(): void + { + Config::set('app.locale', 'ko'); + Config::set('app.supported_locales', null); + + $renderer = SitemapXmlRenderer::fromConfig(); + + $this->assertFalse($renderer->isMultilingual()); + $this->assertSame(1, $renderer->urlBlockCount()); + } + + /** + * isMultilingual: 로케일이 1개면 단일 언어다 + */ + public function test_is_multilingual_is_false_for_single_locale(): void + { + $this->assertFalse($this->singleLocale()->isMultilingual()); + $this->assertTrue($this->multiLocale(['ko', 'en'])->isMultilingual()); + } + + /** + * urlBlockCount: 다국어면 로케일 수, 단일 언어면 1 + * + * 이 값이 틀리면 writer 의 파일당 URL 수 임계 계산이 어긋나 프로토콜을 위반한다. + */ + public function test_url_block_count_matches_locale_count(): void + { + $this->assertSame(1, $this->singleLocale()->urlBlockCount()); + $this->assertSame(3, $this->multiLocale()->urlBlockCount()); + } + + /** + * urlsetHeader: 단일 언어는 xhtml 네임스페이스를 선언하지 않는다 + */ + public function test_urlset_header_omits_xhtml_namespace_for_single_locale(): void + { + $header = $this->singleLocale()->urlsetHeader(); + + $this->assertStringStartsWith('', $header); + $this->assertStringContainsString('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"', $header); + $this->assertStringNotContainsString('xmlns:xhtml', $header); + } + + /** + * urlsetHeader: 다국어는 xhtml 네임스페이스를 선언한다 (hreflang 사용) + */ + public function test_urlset_header_declares_xhtml_namespace_for_multilingual(): void + { + $this->assertStringContainsString( + 'xmlns:xhtml="http://www.w3.org/1999/xhtml"', + $this->multiLocale()->urlsetHeader() + ); + } + + /** + * urlsetFooter: urlset 을 닫는다 + */ + public function test_urlset_footer_closes_urlset(): void + { + $this->assertSame('', $this->singleLocale()->urlsetFooter()); + } + + /** + * urlBlock: loc 이 없으면 빈 문자열을 반환한다 (writer 가 이것으로 skip 판정) + */ + public function test_url_block_returns_empty_string_without_loc(): void + { + $this->assertSame('', $this->singleLocale()->urlBlock([])); + $this->assertSame('', $this->singleLocale()->urlBlock(['loc' => ''])); + $this->assertSame('', $this->singleLocale()->urlBlock(['changefreq' => 'daily'])); + } + + /** + * urlBlock: 단일 언어는 블록 1개를 만들고 hreflang 을 붙이지 않는다 + */ + public function test_url_block_renders_single_url_without_hreflang(): void + { + $xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a']); + + $this->assertSame(1, substr_count($xml, '')); + $this->assertStringContainsString('https://example.test/a', $xml); + $this->assertStringNotContainsString('xhtml:link', $xml); + } + + /** + * urlBlock: 메타 필드를 렌더링하며 priority 는 소수 1자리로 고정한다 + */ + public function test_url_block_renders_meta_fields(): void + { + $xml = $this->singleLocale()->urlBlock([ + 'loc' => 'https://example.test/a', + 'lastmod' => '2026-07-17T00:00:00+09:00', + 'changefreq' => 'daily', + 'priority' => 0.8, + ]); + + $this->assertStringContainsString('2026-07-17T00:00:00+09:00', $xml); + $this->assertStringContainsString('daily', $xml); + $this->assertStringContainsString('0.8', $xml); + } + + /** + * urlBlock: priority 는 정수로 줘도 소수 1자리로 정규화된다 + */ + public function test_url_block_normalizes_integer_priority(): void + { + $xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a', 'priority' => 1]); + + $this->assertStringContainsString('1.0', $xml); + } + + /** + * urlBlock: 메타 필드가 없으면 해당 태그를 생략한다 + */ + public function test_url_block_omits_absent_meta_fields(): void + { + $xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a']); + + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString('', $xml); + } + + /** + * urlBlock: 다국어는 로케일 수만큼 블록을 만든다 + */ + public function test_url_block_renders_one_url_per_locale(): void + { + $xml = $this->multiLocale()->urlBlock(['loc' => 'https://example.test/a']); + + $this->assertSame(3, substr_count($xml, '')); + } + + /** + * urlBlock: 기본 로케일은 원본 URL, 그 외는 ?locale= 를 붙인다 + */ + public function test_url_block_localizes_loc_per_locale(): void + { + $xml = $this->multiLocale()->urlBlock(['loc' => 'https://example.test/a']); + + // 기본 로케일(ko)은 쿼리 없이 원본 + $this->assertStringContainsString('https://example.test/a', $xml); + $this->assertStringContainsString('https://example.test/a?locale=en', $xml); + $this->assertStringContainsString('https://example.test/a?locale=ja', $xml); + } + + /** + * urlBlock: hreflang alternate 가 현재 N² 구조임을 고정한다 (S3 ⑮ 회귀 기준선) + * + * 현재 구현은 블록마다 모든 로케일의 alternate + x-default 를 넣는다. + * 즉 로케일 L 개면 L × (L + 1) 개의 xhtml:link 가 생성된다 — 이것이 S3 ⑮ 가 + * 해소하려는 N² 팽창이다. S3 에서 이 수치가 바뀌면 의도된 변경이므로 + * 이 테스트를 함께 갱신할 것. + */ + public function test_url_block_currently_emits_quadratic_hreflang_links(): void + { + $xml = $this->multiLocale(['ko', 'en', 'ja'])->urlBlock(['loc' => 'https://example.test/a']); + + // 3 로케일 → 3 블록 × (3 alternate + 1 x-default) = 12 + $this->assertSame(12, substr_count($xml, 'xhtml:link')); + $this->assertSame(3, substr_count($xml, 'hreflang="x-default"')); + } + + /** + * urlBlock: x-default 는 항상 기본 로케일 URL 을 가리킨다 + */ + public function test_url_block_x_default_points_to_base_loc(): void + { + $xml = $this->multiLocale()->urlBlock(['loc' => 'https://example.test/a']); + + $this->assertStringContainsString( + '', + $xml + ); + $this->assertStringNotContainsString('hreflang="x-default" href="https://example.test/a?locale=', $xml); + } + + /** + * urlBlock: loc 의 XML 특수문자를 이스케이프한다 (단일 출처 방어) + */ + public function test_url_block_escapes_special_characters_in_loc(): void + { + $xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a?x=1&y=2']); + + $this->assertStringContainsString('https://example.test/a?x=1&y=2<z>', $xml); + $this->assertStringNotContainsString('&y=2', $xml); + } + + /** + * urlBlock: 메타 필드의 XML 특수문자도 이스케이프한다 + */ + public function test_url_block_escapes_special_characters_in_meta_fields(): void + { + $xml = $this->singleLocale()->urlBlock([ + 'loc' => 'https://example.test/a', + 'lastmod' => '2026 & later', + 'changefreq' => 'daassertStringContainsString('2026 & later', $xml); + $this->assertStringContainsString('da<ily', $xml); + } + + /** + * urlBlock: 다국어 alternate href 도 이스케이프한다 + */ + public function test_url_block_escapes_hreflang_alternate_href(): void + { + $xml = $this->multiLocale(['ko', 'en'])->urlBlock(['loc' => 'https://example.test/a?x=1&y=2']); + + $this->assertStringContainsString('href="https://example.test/a?x=1&y=2"', $xml); + // 이스케이프되지 않은 & 가 남으면 XML 파서가 깨진다 + $this->assertStringNotContainsString('&y=2"', $xml); + } + + /** + * urlBlock: 다국어 출력이 well-formed XML 이다 (이스케이프 종합 검증) + */ + public function test_multilingual_url_block_is_well_formed_xml(): void + { + $renderer = $this->multiLocale(); + $xml = $renderer->urlsetHeader() + .$renderer->urlBlock(['loc' => 'https://example.test/a?x=1&y=2', 'changefreq' => 'daily']) + .$renderer->urlsetFooter(); + + $this->assertNotFalse(simplexml_load_string($xml), 'urlset 이 파싱 가능한 XML 이어야 합니다.'); + } + + /** + * sitemapIndex: 자식 목록을 sitemapindex 로 렌더링한다 + */ + public function test_sitemap_index_renders_children(): void + { + $xml = $this->singleLocale()->sitemapIndex([ + ['loc' => 'https://example.test/sitemap-1.xml', 'lastmod' => '2026-07-17T00:00:00+09:00'], + ['loc' => 'https://example.test/sitemap-2.xml', 'lastmod' => null], + ]); + + $this->assertStringStartsWith('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertSame(2, substr_count($xml, '')); + $this->assertStringContainsString('https://example.test/sitemap-1.xml', $xml); + // lastmod 가 null 인 자식은 태그를 생략한다 + $this->assertSame(1, substr_count($xml, '')); + $this->assertStringEndsWith('', $xml); + } + + /** + * sitemapIndex: 자식이 없어도 well-formed 인 빈 인덱스를 만든다 + */ + public function test_sitemap_index_is_well_formed_when_empty(): void + { + $xml = $this->singleLocale()->sitemapIndex([]); + + $this->assertNotFalse(simplexml_load_string($xml)); + $this->assertStringNotContainsString('', $xml); + } + + /** + * sitemapIndex: 자식 loc/lastmod 의 특수문자를 이스케이프한다 + */ + public function test_sitemap_index_escapes_special_characters(): void + { + $xml = $this->singleLocale()->sitemapIndex([ + ['loc' => 'https://example.test/sitemap-1.xml?v=1&t=2', 'lastmod' => '2026 & later'], + ]); + + $this->assertStringContainsString('https://example.test/sitemap-1.xml?v=1&t=2', $xml); + $this->assertStringContainsString('2026 & later', $xml); + $this->assertNotFalse(simplexml_load_string($xml)); + } +} diff --git a/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php b/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php new file mode 100644 index 00000000..bd7afa3e --- /dev/null +++ b/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php @@ -0,0 +1,241 @@ +migration = new NullifyUntouchedSeoCacheOverrides; + } + + /** + * 업그레이드 컨텍스트를 만듭니다. + * + * @return UpgradeContext 컨텍스트 + */ + private function context(): UpgradeContext + { + return new UpgradeContext('7.0.5', '7.1.0', '7.1.0'); + } + + /** + * seo.json 을 기록합니다. + * + * @param array $settings 설정 배열 + */ + private function writeSeoJson(array $settings): void + { + Storage::disk('settings')->put('seo.json', json_encode($settings)); + } + + /** + * seo.json 을 읽습니다. + * + * @return array 설정 배열 + */ + private function readSeoJson(): array + { + return json_decode(Storage::disk('settings')->get('seo.json'), true); + } + + /** + * name: 로그용 식별자를 반환한다 + */ + public function test_name_returns_identifier(): void + { + $this->assertSame('NullifyUntouchedSeoCacheOverrides', $this->migration->name()); + } + + /** + * 옛 기본값 그대로인 키는 null 로 비운다 (안 건드린 것으로 추정) + */ + public function test_clears_keys_left_at_legacy_defaults(): void + { + $this->writeSeoJson([ + 'cache_enabled' => true, + 'cache_ttl' => 7200, + 'sitemap_cache_ttl' => 86400, + 'sitemap_enabled' => true, + ]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertNull($result['cache_enabled']); + $this->assertNull($result['cache_ttl']); + $this->assertNull($result['sitemap_cache_ttl']); + } + + /** + * 옛 기본값과 다른 값은 보존한다 (의도적으로 바꾼 것 → 오버라이드로 발동) + * + * 실제 사례: seo.cache_ttl=3600 을 설정해 뒀으나 그동안 무시되던 사이트. + */ + public function test_preserves_values_that_differ_from_legacy_defaults(): void + { + $this->writeSeoJson([ + 'cache_enabled' => false, + 'cache_ttl' => 3600, + 'sitemap_cache_ttl' => 604800, + ]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertFalse($result['cache_enabled'], 'false 는 기본값(true)과 다르므로 보존돼야 합니다.'); + $this->assertSame(3600, $result['cache_ttl']); + $this->assertSame(604800, $result['sitemap_cache_ttl']); + } + + /** + * 일부만 바뀐 경우 — 바뀐 것만 보존하고 나머지는 비운다 + */ + public function test_clears_untouched_and_preserves_changed_in_same_file(): void + { + $this->writeSeoJson([ + 'cache_enabled' => true, // 기본값 → 비움 + 'cache_ttl' => 3600, // 변경됨 → 보존 + 'sitemap_cache_ttl' => 86400, // 기본값 → 비움 + ]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertNull($result['cache_enabled']); + $this->assertSame(3600, $result['cache_ttl']); + $this->assertNull($result['sitemap_cache_ttl']); + } + + /** + * seo 카테고리의 다른 키는 건드리지 않는다 + */ + public function test_does_not_touch_unrelated_keys(): void + { + $this->writeSeoJson([ + 'cache_ttl' => 7200, + 'sitemap_enabled' => true, + 'sitemap_schedule' => 'hourly', + 'sitemap_schedule_time' => '03:00', + 'meta_title_suffix' => ' | G7', + ]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertTrue($result['sitemap_enabled']); + $this->assertSame('hourly', $result['sitemap_schedule'], '재생성 주기는 이 이행과 무관합니다.'); + $this->assertSame('03:00', $result['sitemap_schedule_time']); + $this->assertSame(' | G7', $result['meta_title_suffix']); + } + + /** + * 멱등: 두 번 실행해도 결과가 같다 + */ + public function test_is_idempotent(): void + { + $this->writeSeoJson([ + 'cache_ttl' => 7200, + 'sitemap_cache_ttl' => 3600, + ]); + + $this->migration->run($this->context()); + $first = $this->readSeoJson(); + + $this->migration->run($this->context()); + $second = $this->readSeoJson(); + + $this->assertSame($first, $second); + $this->assertNull($second['cache_ttl']); + $this->assertSame(3600, $second['sitemap_cache_ttl']); + } + + /** + * 이미 null 인 키는 그대로 둔다 (재실행 안전) + */ + public function test_leaves_already_null_keys_untouched(): void + { + $this->writeSeoJson(['cache_ttl' => null, 'sitemap_cache_ttl' => null]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertNull($result['cache_ttl']); + $this->assertNull($result['sitemap_cache_ttl']); + } + + /** + * 키가 아예 없으면 새로 만들지 않는다 + */ + public function test_does_not_add_absent_keys(): void + { + $this->writeSeoJson(['meta_title_suffix' => ' | G7']); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertArrayNotHasKey('cache_ttl', $result); + $this->assertArrayNotHasKey('sitemap_cache_ttl', $result); + } + + /** + * seo.json 이 없으면 no-op (예외 없음) + */ + public function test_no_op_when_settings_file_is_absent(): void + { + $this->migration->run($this->context()); + + $this->assertFalse(Storage::disk('settings')->exists('seo.json')); + } + + /** + * 손상된 JSON 은 덮어쓰지 않고 보존한다 (파괴 금지) + */ + public function test_preserves_corrupted_json_without_overwriting(): void + { + Storage::disk('settings')->put('seo.json', '{ this is not json'); + + $this->migration->run($this->context()); + + $this->assertSame('{ this is not json', Storage::disk('settings')->get('seo.json')); + } + + /** + * 타입이 다른 동일 값은 오버라이드로 본다 (느슨한 비교 금지) + * + * 문자열 "7200" 은 기본값 7200(int)과 == 로는 같지만, 운영자가 폼으로 입력한 + * 흔적이므로 === 비교로 보존해야 한다. + */ + public function test_treats_type_mismatched_value_as_override(): void + { + $this->writeSeoJson(['cache_ttl' => '7200']); + + $this->migration->run($this->context()); + + $this->assertSame('7200', $this->readSeoJson()['cache_ttl']); + } +} diff --git a/upgrades/Upgrade_7_1_0.php b/upgrades/Upgrade_7_1_0.php new file mode 100644 index 00000000..714b6c91 --- /dev/null +++ b/upgrades/Upgrade_7_1_0.php @@ -0,0 +1,30 @@ + true, + 'cache_ttl' => 7200, + 'sitemap_cache_ttl' => 86400, + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'NullifyUntouchedSeoCacheOverrides'; + } + + /** + * 옛 기본값 그대로인 SEO 캐시 키를 null(미설정)로 비웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + try { + $this->runInternal($context); + } catch (\Throwable $e) { + // 업그레이드 중단 금지 — 설정 정리 실패가 업그레이드 전체를 막지 않는다. + $context->logger->warning(sprintf( + '[7.1.0] NullifyUntouchedSeoCacheOverrides 실패 (seo.json 보존, 계속 진행): %s', + $e->getMessage(), + )); + Log::warning('7.1.0 NullifyUntouchedSeoCacheOverrides 실패', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * 실제 이행 로직. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + private function runInternal(UpgradeContext $context): void + { + $settings = $this->readSeoSettings($context); + if ($settings === null) { + return; + } + + $cleared = []; + $kept = []; + + foreach (self::LEGACY_DEFAULTS as $key => $legacyDefault) { + if (! array_key_exists($key, $settings) || $settings[$key] === null) { + continue; + } + + // 옛 기본값과 완전히 같을 때만 "안 건드림" 으로 본다 (타입까지 일치 요구) + if ($settings[$key] === $legacyDefault) { + $settings[$key] = null; + $cleared[] = $key; + + continue; + } + + $kept[$key] = $settings[$key]; + } + + foreach ($kept as $key => $value) { + $context->logger->info(sprintf( + '[7.1.0] seo.%s = %s 는 옛 기본값과 달라 오버라이드로 보존 — 이제부터 실제 적용됩니다', + $key, + var_export($value, true), + )); + } + + if ($cleared === []) { + $context->logger->info('[7.1.0] SEO 캐시 설정 — 비울 대상 없음 (이미 이행 완료이거나 전부 오버라이드)'); + + return; + } + + $this->writeSeoSettings($settings); + $context->logger->info('[7.1.0] SEO 캐시 설정 — 미설정(null)으로 비운 키: '.implode(', ', $cleared)); + } + + /** + * seo 설정 카테고리를 읽습니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @return array|null 설정 배열 (파일 부재/손상 시 null) + */ + private function readSeoSettings(UpgradeContext $context): ?array + { + if (! Storage::disk('settings')->exists(self::SEO_SETTINGS_FILE)) { + $context->logger->info('[7.1.0] seo.json 부재 — 정리 대상 없음, skip'); + + return null; + } + + $decoded = json_decode((string) Storage::disk('settings')->get(self::SEO_SETTINGS_FILE), true); + + if (! is_array($decoded)) { + // 손상 파일을 덮어쓰지 않는다 (파괴 금지) + $context->logger->warning('[7.1.0] seo.json 파싱 실패 — 파일 보존하고 skip'); + + return null; + } + + return $decoded; + } + + /** + * seo 설정 카테고리를 저장합니다. + * + * @param array $settings 저장할 설정 배열 + */ + private function writeSeoSettings(array $settings): void + { + Storage::disk('settings')->put( + self::SEO_SETTINGS_FILE, + json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ); + } +}