diff --git a/app/Console/Commands/SeoGenerateSitemapCommand.php b/app/Console/Commands/SeoGenerateSitemapCommand.php index 1198e680..9bf5f1aa 100644 --- a/app/Console/Commands/SeoGenerateSitemapCommand.php +++ b/app/Console/Commands/SeoGenerateSitemapCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Enums\SitemapGenerationMode; use App\Jobs\GenerateSitemapJob; use Illuminate\Console\Command; use Illuminate\Support\Facades\Config; @@ -17,7 +18,10 @@ class SeoGenerateSitemapCommand extends Command /** * @var string 커맨드 시그니처 */ - protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}'; + protected $signature = 'seo:generate-sitemap + {--sync : 큐 드라이버를 무시하고 동기 실행} + {--rebuild : 저장소 상태와 무관하게 전체 재생성 (--mode=full 과 동일)} + {--mode=auto : 재생성 모드 (full|auto|incremental)}'; /** * @var string 커맨드 설명 @@ -31,6 +35,14 @@ class SeoGenerateSitemapCommand extends Command */ public function handle(): int { + $mode = $this->resolveMode(); + if ($mode === null) { + $allowed = implode(', ', array_map(fn (SitemapGenerationMode $m): string => $m->value, SitemapGenerationMode::cases())); + $this->error('잘못된 모드입니다. 허용: '.$allowed); + + return Command::INVALID; + } + $forceSync = (bool) $this->option('sync'); // queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되, // testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다. @@ -38,13 +50,29 @@ class SeoGenerateSitemapCommand extends Command $isSyncDriver = $connection === 'sync'; if ($forceSync || $isSyncDriver) { - GenerateSitemapJob::dispatchSync(); - $this->info('Sitemap이 생성되었습니다.'); + GenerateSitemapJob::dispatchSync($mode); + $this->info("Sitemap이 생성되었습니다. (모드: {$mode->value})"); } else { - GenerateSitemapJob::dispatch(); - $this->info('Sitemap 생성이 큐에 디스패치되었습니다.'); + GenerateSitemapJob::dispatch($mode); + $this->info("Sitemap 생성이 큐에 디스패치되었습니다. (모드: {$mode->value})"); } return Command::SUCCESS; } + + /** + * --rebuild / --mode 옵션으로부터 재생성 모드를 해석합니다. + * + * --rebuild 는 --mode 보다 우선하며 Full 로 강제합니다. + * + * @return SitemapGenerationMode|null 재생성 모드 (유효하지 않으면 null) + */ + private function resolveMode(): ?SitemapGenerationMode + { + if ((bool) $this->option('rebuild')) { + return SitemapGenerationMode::Full; + } + + return SitemapGenerationMode::tryFrom((string) $this->option('mode')); + } } diff --git a/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php new file mode 100644 index 00000000..44c619f7 --- /dev/null +++ b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php @@ -0,0 +1,61 @@ + $entries URL 항목 + */ + public function upsertForResource(string $type, string $id, array $entries): void; + + /** + * 한 리소스의 사이트맵 URL 행을 모두 제거합니다(비공개/삭제 시). + * + * @param string $type 리소스 유형 + * @param string $id 리소스 PK (문자열) + */ + public function removeForResource(string $type, string $id): void; + + /** + * 공개(is_visible) URL 을 id 순으로 스트리밍합니다(유계 메모리). + * + * @param string|null $contributor 기여자 식별자로 스코핑 (null = 전체) + * @return iterable URL 순회자 + */ + public function streamVisible(?string $contributor = null): iterable; + + /** + * 공개(is_visible) URL 총 개수를 반환합니다. + * + * @return int 공개 URL 수 + */ + public function countVisible(): int; + + /** + * 한 기여자의 모든 사이트맵 URL 행을 전량 대체합니다(전체 재생성). + * + * 기여자 스코프로 기존 행을 제거한 뒤 스트림을 청크 삽입하여 + * 대용량에서도 메모리를 유계로 유지합니다. + * + * @param string $contributor 기여자 식별자 + * @param iterable $entries URL 항목 스트림 + */ + public function replaceAllForContributor(string $contributor, iterable $entries): void; +} diff --git a/app/Enums/SitemapGenerationMode.php b/app/Enums/SitemapGenerationMode.php new file mode 100644 index 00000000..32384d88 --- /dev/null +++ b/app/Enums/SitemapGenerationMode.php @@ -0,0 +1,48 @@ + self::Full, + self::Incremental => self::Incremental, + self::Auto => $visibleCount === 0 ? self::Full : self::Incremental, + }; + } +} diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php index d667ceec..a061c8d8 100644 --- a/app/Jobs/GenerateSitemapJob.php +++ b/app/Jobs/GenerateSitemapJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Enums\SitemapGenerationMode; use App\Seo\SitemapManager; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -44,6 +45,13 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue */ public int $uniqueFor = 900; + /** + * GenerateSitemapJob 생성자 + * + * @param SitemapGenerationMode $mode 재생성 모드 — SitemapManager 로 전달 + */ + public function __construct(public SitemapGenerationMode $mode = SitemapGenerationMode::Auto) {} + /** * 유니크 락 식별자를 반환합니다. * @@ -75,7 +83,7 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue */ public function handle(SitemapManager $manager): void { - $result = $manager->regenerate(); + $result = $manager->regenerate($this->mode); if (($result['status'] ?? null) === 'disabled') { Log::info('[SEO] Sitemap generation skipped (disabled)'); diff --git a/app/Models/SitemapUrl.php b/app/Models/SitemapUrl.php new file mode 100644 index 00000000..72318add --- /dev/null +++ b/app/Models/SitemapUrl.php @@ -0,0 +1,61 @@ + 대량 할당 가능 컬럼 + */ + protected $fillable = [ + 'resource_type', + 'resource_id', + 'loc', + 'loc_hash', + 'lastmod', + 'changefreq', + 'priority', + 'contributor', + 'is_visible', + ]; + + /** + * 속성 캐스팅을 반환합니다. + * + * @return array 캐스팅 맵 + */ + protected function casts(): array + { + return [ + 'lastmod' => 'datetime', + 'priority' => 'float', + 'is_visible' => 'boolean', + ]; + } +} diff --git a/app/Repositories/SitemapUrlRepository.php b/app/Repositories/SitemapUrlRepository.php new file mode 100644 index 00000000..9e9541c9 --- /dev/null +++ b/app/Repositories/SitemapUrlRepository.php @@ -0,0 +1,174 @@ +where('resource_type', $type) + ->where('resource_id', $id) + ->delete(); + + $now = now(); + $rows = []; + foreach ($entries as $entry) { + $rows[] = $this->toRow( + $type, + $id, + (string) ($entry['contributor'] ?? ''), + $entry, + $now, + ); + } + + if ($rows !== []) { + SitemapUrl::insert($rows); + } + }); + } + + /** + * {@inheritDoc} + */ + public function removeForResource(string $type, string $id): void + { + SitemapUrl::query() + ->where('resource_type', $type) + ->where('resource_id', $id) + ->delete(); + } + + /** + * {@inheritDoc} + */ + public function streamVisible(?string $contributor = null): iterable + { + $query = SitemapUrl::query()->where('is_visible', true); + + if ($contributor !== null) { + $query->where('contributor', $contributor); + } + + // lazyById 는 id 키셋 페이징이라 결과셋 전체를 메모리/드라이버 버퍼에 적재하지 않습니다. + return $query->lazyById(self::STREAM_CHUNK); + } + + /** + * {@inheritDoc} + */ + public function countVisible(): int + { + return SitemapUrl::query()->where('is_visible', true)->count(); + } + + /** + * {@inheritDoc} + */ + public function replaceAllForContributor(string $contributor, iterable $entries): void + { + // 기여자 스코프 전량 제거 후 청크 삽입. 대용량(수백만) 삽입을 단일 트랜잭션으로 + // 감싸면 락/메모리 부담이 크므로, 삭제 후 청크별 삽입으로 유계 메모리를 유지합니다. + SitemapUrl::query()->where('contributor', $contributor)->delete(); + + $now = now(); + $batch = []; + foreach ($entries as $entry) { + $batch[] = $this->toRow( + (string) ($entry['resource_type'] ?? 'unknown'), + isset($entry['resource_id']) && $entry['resource_id'] !== null + ? (string) $entry['resource_id'] + : null, + $contributor, + $entry, + $now, + ); + + if (count($batch) >= self::INSERT_CHUNK) { + SitemapUrl::insert($batch); + $batch = []; + } + } + + if ($batch !== []) { + SitemapUrl::insert($batch); + } + } + + /** + * URL 항목을 sitemap_urls 삽입용 행으로 변환합니다. + * + * @param string $type 리소스 유형 + * @param string|null $id 리소스 PK (문자열, 없으면 null) + * @param string $contributor 기여자 식별자 + * @param array $entry URL 항목 (loc/lastmod/changefreq/priority) + * @param Carbon $now 타임스탬프 기준값 + * @return array 삽입용 행 + */ + private function toRow(string $type, ?string $id, string $contributor, array $entry, Carbon $now): array + { + $loc = (string) ($entry['loc'] ?? ''); + + return [ + 'resource_type' => $type, + 'resource_id' => $id, + 'loc' => $loc, + 'loc_hash' => hash('sha256', $loc), + 'lastmod' => $this->normalizeLastmod($entry['lastmod'] ?? null), + 'changefreq' => $entry['changefreq'] ?? null, + 'priority' => $entry['priority'] ?? null, + 'contributor' => $contributor, + 'is_visible' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + /** + * lastmod 값을 DB 저장용 타임스탬프 문자열로 정규화합니다. + * + * insert() 는 모델 캐스팅을 우회하므로 W3C 문자열/Carbon 을 직접 파싱합니다. + * + * @param mixed $lastmod 원본 lastmod (문자열/Carbon/null) + * @return string|null 정규화된 'Y-m-d H:i:s' 또는 null + */ + private function normalizeLastmod(mixed $lastmod): ?string + { + if ($lastmod === null || $lastmod === '') { + return null; + } + + try { + return Carbon::parse($lastmod)->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Seo/SeoServiceProvider.php b/app/Seo/SeoServiceProvider.php index bbc76844..f2b73212 100644 --- a/app/Seo/SeoServiceProvider.php +++ b/app/Seo/SeoServiceProvider.php @@ -3,7 +3,9 @@ namespace App\Seo; use App\Contracts\Extension\StorageInterface; +use App\Contracts\Repositories\SitemapUrlRepositoryInterface; use App\Extension\Storage\CoreStorageDriver; +use App\Repositories\SitemapUrlRepository; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\Contracts\SeoRendererInterface; use Illuminate\Support\ServiceProvider; @@ -18,6 +20,7 @@ class SeoServiceProvider extends ServiceProvider // 인터페이스 → 구현체 바인딩 $this->app->singleton(SeoCacheManagerInterface::class, SeoCacheManager::class); $this->app->singleton(SeoRendererInterface::class, SeoRenderer::class); + $this->app->singleton(SitemapUrlRepositoryInterface::class, SitemapUrlRepository::class); // 싱글톤 등록 $this->app->singleton(SeoCacheRegenerator::class); @@ -31,6 +34,7 @@ class SeoServiceProvider extends ServiceProvider $this->app->singleton(SitemapGenerator::class); $this->app->singleton(SeoConfigMerger::class); $this->app->singleton(SitemapFileStore::class); + $this->app->singleton(SitemapIndexer::class); // Sitemap 파일 입출력은 비공개 디스크(local = storage/app/private, serve=false)를 사용합니다. // public/storage 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다. diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php index 0bf8acab..ff747280 100644 --- a/app/Seo/SitemapGenerator.php +++ b/app/Seo/SitemapGenerator.php @@ -76,6 +76,51 @@ class SitemapGenerator return $writer->commit(); } + /** + * 미리 수집된 URL 항목(사이트맵 저장소 스트림 등)을 writer 로 스트리밍하여 파일을 생성합니다. + * + * 정적 라우트를 먼저 기록하고, 이어서 전달된 항목을 한 건씩 writer 에 넘깁니다. + * 증분/전체 재생성 모두 이 경로로 파일을 작성하며, 항목의 출처(도메인 재쿼리 vs 테이블 스트림)는 + * 호출자가 결정합니다. + * + * @param SitemapWriter $writer 분할 기록기 + * @param iterable $entries URL 항목 스트림 + * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array} + * 커밋된 sitemap 메타데이터 + */ + public function generateToWriterFromEntries(SitemapWriter $writer, iterable $entries): array + { + $writer->open(); + + foreach ($this->collectStaticRoutes() as $entry) { + $writer->addUrl($entry); + } + + foreach ($entries as $entry) { + $writer->addUrl($this->normalizeEntry($entry)); + } + + $writer->close(); + + return $writer->commit(); + } + + /** + * 한 기여자의 URL 항목을 절대 URL(loc)로 정규화하여 지연 스트리밍합니다. + * + * 전체 재생성 시 사이트맵 저장소에 적재할 항목의 단일 출처이며, + * 기여자가 emit 한 resource_type/resource_id 등 부가 키는 그대로 통과시킵니다. + * + * @param SitemapContributorInterface $contributor Sitemap 기여자 + * @return iterable> 정규화된 URL 항목 순회자 + */ + public function streamContributorEntries(SitemapContributorInterface $contributor): iterable + { + foreach ($this->drain($contributor) as $entry) { + yield $this->normalizeEntry($entry); + } + } + /** * 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다. * diff --git a/app/Seo/SitemapIndexer.php b/app/Seo/SitemapIndexer.php new file mode 100644 index 00000000..b30615fe --- /dev/null +++ b/app/Seo/SitemapIndexer.php @@ -0,0 +1,112 @@ + $entries URL 항목 + */ + public function indexResource(string $type, string|int $id, string $contributor, array $entries): void + { + $entries = HookManager::applyFilters(self::COLLECT_FILTER, $entries, $type, $id, $contributor); + + if (! is_array($entries) || $entries === []) { + // 색인할 항목이 없으면(모두 제외됨) 기존 색인을 제거해 정합을 유지합니다. + $this->repository->removeForResource($type, (string) $id); + + return; + } + + $normalized = []; + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + + $loc = $this->resolveLoc($entry); + if ($loc === null) { + continue; + } + + $normalized[] = [ + 'loc' => $loc, + 'lastmod' => $entry['lastmod'] ?? null, + 'changefreq' => $entry['changefreq'] ?? null, + 'priority' => $entry['priority'] ?? null, + 'contributor' => $contributor, + ]; + } + + if ($normalized === []) { + $this->repository->removeForResource($type, (string) $id); + + return; + } + + $this->repository->upsertForResource($type, (string) $id, $normalized); + } + + /** + * 비공개/삭제된 리소스의 사이트맵 URL 색인을 제거합니다. + * + * @param string $type 리소스 유형 + * @param string|int $id 리소스 PK + */ + public function deindexResource(string $type, string|int $id): void + { + $this->repository->removeForResource($type, (string) $id); + } + + /** + * 항목의 절대 URL(loc)을 해석합니다. + * + * 'loc'(절대 URL)이 있으면 그대로, 없으면 'url'(상대 경로)을 url() 로 절대화합니다. + * + * @param array $entry URL 항목 + * @return string|null 절대 URL (해석 불가 시 null) + */ + private function resolveLoc(array $entry): ?string + { + if (isset($entry['loc']) && is_string($entry['loc']) && $entry['loc'] !== '') { + return $entry['loc']; + } + + if (isset($entry['url']) && is_string($entry['url']) && $entry['url'] !== '') { + return url($entry['url']); + } + + return null; + } +} diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index 8ae7473b..25b0eb7f 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -5,6 +5,8 @@ namespace App\Seo; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\StorageInterface; use App\Contracts\Repositories\ConfigRepositoryInterface; +use App\Contracts\Repositories\SitemapUrlRepositoryInterface; +use App\Enums\SitemapGenerationMode; use App\Extension\HookManager; use Illuminate\Support\Facades\Log; @@ -33,15 +35,22 @@ class SitemapManager private CacheInterface $cache, private ConfigRepositoryInterface $configRepository, private StorageInterface $storage, + private SitemapUrlRepositoryInterface $repository, ) {} /** * Sitemap 을 즉시 생성하여 디스크에 커밋하고 last_updated_at 을 기록합니다. * + * 모드에 따라 사이트맵 저장소(sitemap_urls) 활용 방식이 달라집니다: + * - full: 각 기여자를 스트리밍해 저장소를 전량 대체한 뒤 저장소 → 파일 재작성. + * - incremental: 저장소의 현재 상태(리스너가 반영한 델타)를 그대로 파일로 재작성. + * - auto: 저장소가 비어 있으면 full, 아니면 incremental. + * + * @param SitemapGenerationMode $mode 재생성 모드 * @return array{success: bool, status: string, message?: string, data?: array} * status: 'updated' | 'disabled' | 'failed' */ - public function regenerate(): array + public function regenerate(SitemapGenerationMode $mode = SitemapGenerationMode::Auto): array { $enabled = (bool) g7_core_settings('seo.sitemap_enabled', true); if (! $enabled) { @@ -55,7 +64,16 @@ class SitemapManager HookManager::doAction('core.seo.sitemap.before_regenerate'); try { - $meta = $this->generator->generateToWriter($this->makeWriter()); + $effectiveMode = $mode->resolve($this->repository->countVisible()); + + if ($effectiveMode === SitemapGenerationMode::Full) { + $this->rebuildStore(); + } + + $meta = $this->generator->generateToWriterFromEntries( + $this->makeWriter(), + $this->streamStoreEntries(), + ); // 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19) $ttl = SeoCacheSettings::sitemapCacheTtl(); @@ -114,6 +132,45 @@ class SitemapManager ]; } + /** + * 각 기여자를 스트리밍하여 사이트맵 저장소를 전량 대체합니다(전체 재생성). + * + * 기여자별로 격리하여, 한 기여자가 실패해도 나머지 기여자의 재적재는 계속됩니다. + */ + private function rebuildStore(): void + { + foreach ($this->generator->getContributors() as $contributor) { + try { + $this->repository->replaceAllForContributor( + $contributor->getIdentifier(), + $this->generator->streamContributorEntries($contributor), + ); + } catch (\Throwable $e) { + Log::warning('[SEO] Sitemap 저장소 재적재 실패', [ + 'contributor' => $contributor->getIdentifier(), + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * 사이트맵 저장소의 공개 URL 을 writer 항목 형태로 지연 스트리밍합니다. + * + * @return iterable writer 항목 순회자 + */ + private function streamStoreEntries(): iterable + { + foreach ($this->repository->streamVisible() as $row) { + yield [ + 'loc' => (string) $row->loc, + 'lastmod' => $row->lastmod?->toW3cString(), + 'changefreq' => $row->changefreq, + 'priority' => $row->priority !== null ? (float) $row->priority : null, + ]; + } + } + /** * 설정값을 반영한 SitemapWriter 를 생성합니다. * diff --git a/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php b/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php new file mode 100644 index 00000000..d1ae85e0 --- /dev/null +++ b/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php @@ -0,0 +1,53 @@ +bigIncrements('id')->comment('사이트맵 URL 행 ID'); + $table->string('resource_type', 64)->index()->comment('리소스 유형 (page, board_post, product, category, board, board_index, shop_index 등)'); + $table->string('resource_id', 64)->nullable()->comment('리소스 PK (uuid/int 문자열). 인덱스/컬렉션 URL 은 null'); + $table->string('loc', 2048)->comment('절대 base URL (로케일 무관 — 렌더 시점에 로케일별 확장)'); + // loc 는 2048 자라 unique 인덱스에 직접 넣으면 InnoDB 키 길이(3072바이트) 를 초과하므로 + // sha256 해시(64자 hex)를 identity 로 사용합니다. ascii 컬럼이라 인덱스 비용이 작습니다. + $table->char('loc_hash', 64)->charset('ascii')->comment('loc 의 sha256 해시 (upsert/delete identity)'); + $table->timestamp('lastmod')->nullable()->comment('최종 수정 시각 ()'); + $table->string('changefreq', 16)->nullable()->comment('변경 빈도 ()'); + $table->decimal('priority', 2, 1)->nullable()->comment('우선순위 0.0~1.0 ()'); + $table->string('contributor', 64)->comment('기여자 식별자 (getIdentifier — 샤드/리빌드 스코핑)'); + $table->boolean('is_visible')->default(true)->comment('공개 여부 (true 만 사이트맵에 노출)'); + $table->timestamps(); + + // 리소스별 URL identity — upsert/delete 대상 식별 + $table->unique(['resource_type', 'resource_id', 'loc_hash'], 'sitemap_urls_identity_unique'); + // 리빌드/증분 스트리밍 커서 (contributor 스코핑 + 가시성 + id 순서) + $table->index(['contributor', 'is_visible', 'id'], 'sitemap_urls_stream_index'); + }); + } + + /** + * sitemap_urls 테이블을 제거합니다. + */ + public function down(): void + { + if (Schema::hasTable('sitemap_urls')) { + Schema::drop('sitemap_urls'); + } + } +}; diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 5b09aa83..087c00c0 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. +- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다. ### Changed diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php index 3b9fc5c7..ae6b0308 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php @@ -4,9 +4,12 @@ namespace Modules\Sirsoft\Board\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Board\Enums\PostStatus; /** * 게시글 변경 시 SEO 캐시 무효화 리스너 @@ -65,6 +68,8 @@ class SeoBoardCacheListener implements HookListenerInterface // 게시글 상세 페이지 캐시 즉시 재생성 $this->regenerateDetailCache($args); + + $this->syncPostSitemapIndex($args, false); } /** @@ -78,6 +83,8 @@ class SeoBoardCacheListener implements HookListenerInterface // 게시글 상세 페이지 캐시 즉시 재생성 $this->regenerateDetailCache($args); + + $this->syncPostSitemapIndex($args, false); } /** @@ -90,6 +97,8 @@ class SeoBoardCacheListener implements HookListenerInterface public function onPostDelete(...$args): void { $this->invalidateRelatedCaches($args); + + $this->syncPostSitemapIndex($args, true); } /** @@ -141,6 +150,96 @@ class SeoBoardCacheListener implements HookListenerInterface 'board_slug' => $slug, ]); } + + $this->syncBoardSitemapIndex($board); + } + + /** + * 게시글의 사이트맵 색인을 증분 갱신합니다. + * + * 게시 상태(Published)이고 비밀글이 아니며 'SEO 제공 페이지(게시글 상세)' 토글이 켜져 있으면 + * 색인(upsert), 아니면 색인 해제(remove)한 뒤 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 BoardSitemapContributor 의 게시글 URL 규칙과 일치해야 합니다. + * + * @param array $args 훅 인자 배열 (첫 번째: Post 모델, 두 번째: 게시판 slug) + * @param bool $deleted 삭제 이벤트 여부 + */ + private function syncPostSitemapIndex(array $args, bool $deleted): void + { + $post = $args[0] ?? null; + $slug = $args[1] ?? null; + + if (! $post || ! isset($post->id) || ! $slug) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = ! $deleted + && ($post->status ?? null) === PostStatus::Published + && ! (bool) ($post->is_secret ?? false) + && (bool) g7_module_settings('sirsoft-board', 'seo.seo_post_detail', true); + + if ($visible) { + $indexer->indexResource('board_post', $post->id, 'sirsoft-board', [[ + 'url' => "/board/{$slug}/{$post->id}", + 'lastmod' => $post->updated_at?->toW3cString(), + 'changefreq' => 'monthly', + 'priority' => 0.5, + ]]); + } else { + $indexer->deindexResource('board_post', $post->id); + } + + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Board post sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'post_id' => is_object($post) ? ($post->id ?? null) : null, + ]); + } + } + + /** + * 게시판의 사이트맵 색인을 증분 갱신합니다. + * + * 활성(is_active) 이며 'SEO 제공 페이지(게시판)' 토글이 켜져 있으면 색인(upsert), + * 아니면 색인 해제(remove)한 뒤 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 BoardSitemapContributor 의 게시판 URL 규칙과 일치해야 합니다. + * + * @param mixed $board Board 모델 + */ + private function syncBoardSitemapIndex(mixed $board): void + { + if (! is_object($board) || ! isset($board->id) || ! isset($board->slug)) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = (bool) ($board->is_active ?? false) + && (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true); + + if ($visible) { + $indexer->indexResource('board', $board->id, 'sirsoft-board', [[ + 'url' => "/board/{$board->slug}", + 'lastmod' => $board->updated_at?->toW3cString(), + 'changefreq' => 'daily', + 'priority' => 0.6, + ]]); + } else { + $indexer->deindexResource('board', $board->id); + } + + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Board sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'board_id' => is_object($board) ? ($board->id ?? null) : null, + ]); + } } /** diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php index fc3c8076..15a48832 100644 --- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php +++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php @@ -84,6 +84,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor 'url' => '/boards', 'changefreq' => 'weekly', 'priority' => 0.5, + 'resource_type' => 'board_index', + 'resource_id' => null, ]; } @@ -100,6 +102,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor 'lastmod' => $board->updated_at?->toW3cString(), 'changefreq' => 'daily', 'priority' => 0.6, + 'resource_type' => 'board', + 'resource_id' => (string) $board->id, ]; } @@ -114,6 +118,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor 'lastmod' => $post->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, + 'resource_type' => 'board_post', + 'resource_id' => (string) $post->id, ]; } } diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php new file mode 100644 index 00000000..588eb528 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php @@ -0,0 +1,144 @@ +app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class)); + $this->app->instance(SeoCacheRegenerator::class, Mockery::spy(SeoCacheRegenerator::class)); + Bus::fake([GenerateSitemapJob::class]); + + $this->listener = new SeoBoardCacheListener; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * 게시(Published)·비밀글 아님 게시글 생성 시 색인(append)된다. + */ + public function test_published_public_post_is_indexed(): void + { + $post = (object) ['id' => 100, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()]; + + $this->listener->onPostCreate($post, 'notice'); + + $row = SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '100')->first(); + $this->assertNotNull($row, '공개 게시글은 색인돼야 합니다.'); + $this->assertStringEndsWith('/board/notice/100', $row->loc); + Bus::assertDispatched(GenerateSitemapJob::class); + } + + /** + * 비밀글은 색인되지 않는다(전시 상태여도). + */ + public function test_secret_post_is_not_indexed(): void + { + $post = (object) ['id' => 101, 'status' => PostStatus::Published, 'is_secret' => true, 'updated_at' => now()]; + + $this->listener->onPostCreate($post, 'notice'); + + $this->assertSame(0, SitemapUrl::where('resource_id', '101')->count(), '비밀글은 색인되면 안 됩니다.'); + } + + /** + * 블라인드 처리(수정)되면 색인이 제거된다. + */ + public function test_blinded_post_update_removes_index(): void + { + $this->listener->onPostCreate( + (object) ['id' => 102, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '102')->count()); + + $this->listener->onPostUpdate( + (object) ['id' => 102, 'status' => PostStatus::Blinded, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + + $this->assertSame(0, SitemapUrl::where('resource_id', '102')->count(), '블라인드 게시글은 색인에서 제거돼야 합니다.'); + } + + /** + * 게시글 삭제 시 색인이 제거된다. + */ + public function test_post_delete_removes_index(): void + { + $this->listener->onPostCreate( + (object) ['id' => 103, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '103')->count()); + + $this->listener->onPostDelete((object) ['id' => 103], 'notice'); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '103')->count()); + } + + /** + * 활성/비활성 게시판 수정이 색인을 토글한다. + */ + public function test_board_visibility_toggles_index(): void + { + $this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => true, 'updated_at' => now()]); + $row = SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->first(); + $this->assertNotNull($row); + $this->assertStringEndsWith('/board/free', $row->loc); + + $this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => false, 'updated_at' => now()]); + $this->assertSame(0, SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->count()); + } + + /** + * 'SEO 제공 페이지(게시글 상세)' 토글이 OFF 면 공개 게시글이어도 색인이 제거된다. + * + * 리스너의 공개상태 판정은 게시 상태·비밀글 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치). + */ + public function test_post_detail_toggle_off_removes_index_even_when_public(): void + { + // 토글 ON 상태로 먼저 색인 + $this->listener->onPostCreate( + (object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '110')->count()); + + // 토글 OFF → 공개 게시글이어도 색인 제거 + Config::set('g7_settings.modules.sirsoft-board.seo.seo_post_detail', false); + $this->listener->onPostUpdate( + (object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + + $this->assertSame(0, SitemapUrl::where('resource_id', '110')->count(), 'seo_post_detail OFF 면 공개 게시글도 색인에서 제거돼야 합니다.'); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php index 5f3cd456..1da4b733 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php @@ -2,8 +2,12 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Listeners; +use App\Contracts\Extension\CacheInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; use Mockery; use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener; @@ -38,6 +42,10 @@ class SeoBoardCacheListenerTest extends ModuleTestCase $this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class); $this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock); + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoBoardCacheListener; } @@ -77,6 +85,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase * 이 훅 미구독이 "게시판 정보 수정 후 SEO 메타 캐시 미무효화" 버그의 원인이었습니다. * * @scenario name_changed=no, description_changed=no + * * @effects board_after_update_hook_subscribed */ public function test_subscribes_board_after_update_hook(): void @@ -98,6 +107,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase * sitemap 캐시를 무효화해야 합니다. (게시글 상세는 board name 미노출이라 제외) * * @scenario name_changed=yes, description_changed=no + * * @effects board_update_invalidates_board_list_url, board_update_invalidates_boards_index_url, board_update_invalidates_home_and_search_layouts, board_update_invalidates_sitemap_cache */ public function test_on_board_update_invalidates_board_seo_caches(): void @@ -390,9 +400,9 @@ class SeoBoardCacheListenerTest extends ModuleTestCase }); // sitemap 캐시(seo.sitemap) forget 검증 — CacheInterface - $cacheInterfaceMock = Mockery::mock(\App\Contracts\Extension\CacheInterface::class); + $cacheInterfaceMock = Mockery::mock(CacheInterface::class); $cacheInterfaceMock->shouldReceive('forget')->once()->with('seo.sitemap'); - $this->app->instance(\App\Contracts\Extension\CacheInterface::class, $cacheInterfaceMock); + $this->app->instance(CacheInterface::class, $cacheInterfaceMock); Log::shouldReceive('debug')->atLeast()->once(); diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 2c308f92..fa2706f7 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. +- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다. ### Changed diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php index 9e81ce67..99f22ec4 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php @@ -4,7 +4,9 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; +use App\Seo\SitemapIndexer; use Illuminate\Support\Facades\Log; /** @@ -32,7 +34,7 @@ class SeoCategoryCacheListener implements HookListenerInterface 'priority' => 20, ], 'sirsoft-ecommerce.category.after_delete' => [ - 'method' => 'onCategoryChange', + 'method' => 'onCategoryDelete', 'priority' => 20, ], ]; @@ -87,5 +89,75 @@ class SeoCategoryCacheListener implements HookListenerInterface 'error' => $e->getMessage(), ]); } + + $this->syncSitemapIndex($category); + } + + /** + * 카테고리 삭제 시 캐시를 무효화하고 사이트맵 색인을 제거합니다. + * + * after_delete 훅은 모델이 아닌 카테고리 ID(int)를 전달합니다. + * + * @param mixed ...$args 훅 인자 (첫 번째: 카테고리 ID) + */ + public function onCategoryDelete(...$args): void + { + $this->onCategoryChange(...$args); + + $categoryId = $args[0] ?? null; + if ($categoryId === null) { + return; + } + + try { + app(SitemapIndexer::class)->deindexResource('category', is_object($categoryId) ? $categoryId->id : $categoryId); + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Category sitemap index removal failed', [ + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 카테고리의 사이트맵 색인을 증분 갱신합니다. + * + * 활성(is_active) 이며 'SEO 제공 페이지(카테고리)' 토글이 켜져 있으면 색인(upsert), + * 아니면 색인 해제(remove)한 뒤, 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 EcommerceSitemapContributor 의 카테고리 URL 규칙과 일치해야 합니다. + * + * @param mixed $category Category 모델 (create/update) + */ + private function syncSitemapIndex(mixed $category): void + { + if (! is_object($category) || ! isset($category->id) || ! isset($category->slug)) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = (bool) ($category->is_active ?? false) + && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true); + + if ($visible) { + $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop'); + $indexer->indexResource('category', $category->id, 'sirsoft-ecommerce', [[ + 'url' => "/{$routePath}/category/{$category->slug}", + 'lastmod' => $category->updated_at?->toW3cString(), + 'changefreq' => 'weekly', + 'priority' => 0.6, + ]]); + } else { + $indexer->deindexResource('category', $category->id); + } + + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Category sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'category_id' => $category->id ?? null, + ]); + } } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php index ee2d86cb..ca75d822 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php @@ -4,9 +4,12 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; /** * 상품 변경 시 SEO 캐시 무효화 리스너 @@ -59,6 +62,7 @@ class SeoProductCacheListener implements HookListenerInterface { $this->invalidateRelatedCaches($args); $this->regenerateDetailCache($args); + $this->syncSitemapIndex($args, false); } /** @@ -70,6 +74,7 @@ class SeoProductCacheListener implements HookListenerInterface { $this->invalidateRelatedCaches($args); $this->regenerateDetailCache($args); + $this->syncSitemapIndex($args, false); } /** @@ -82,6 +87,7 @@ class SeoProductCacheListener implements HookListenerInterface public function onProductDelete(...$args): void { $this->invalidateRelatedCaches($args); + $this->syncSitemapIndex($args, true); } /** @@ -127,6 +133,53 @@ class SeoProductCacheListener implements HookListenerInterface } } + /** + * 상품의 사이트맵 색인을 증분 갱신합니다. + * + * 전시(display_status=VISIBLE)이며 'SEO 제공 페이지(상품 상세)' 토글이 켜져 있으면 색인(upsert), + * 비공개/토글 OFF/삭제이면 색인 해제(remove)한 뒤, 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 EcommerceSitemapContributor 의 상품 URL 규칙과 일치해야 합니다. + * + * @param array $args 훅 인자 배열 (첫 번째: Product 모델) + * @param bool $deleted 삭제 이벤트 여부 + */ + private function syncSitemapIndex(array $args, bool $deleted): void + { + $product = $args[0] ?? null; + + if (! $product || ! isset($product->id)) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = ! $deleted + && ($product->display_status ?? null) === ProductDisplayStatus::VISIBLE + && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true); + + if ($visible) { + $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop'); + $indexer->indexResource('product', $product->id, 'sirsoft-ecommerce', [[ + 'url' => "/{$routePath}/products/{$product->id}", + 'lastmod' => $product->updated_at?->toW3cString(), + 'changefreq' => 'weekly', + 'priority' => 0.8, + ]]); + } else { + $indexer->deindexResource('product', $product->id); + } + + // 파일 재작성은 잡에 위임 (ShouldBeUnique 로 디바운스). auto 모드 → 저장소 기반 증분. + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Product sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'product_id' => $product->id ?? null, + ]); + } + } + /** * 상품 상세 페이지의 SEO 캐시를 즉시 재생성합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php index be602fa7..22951383 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php @@ -81,6 +81,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor 'url' => "/{$routePath}/products", 'changefreq' => 'daily', 'priority' => 0.7, + 'resource_type' => 'shop_index', + 'resource_id' => null, ]; } @@ -92,6 +94,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor 'lastmod' => $category->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.6, + 'resource_type' => 'category', + 'resource_id' => (string) $category->id, ]; } } @@ -104,6 +108,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor 'lastmod' => $product->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.8, + 'resource_type' => 'product', + 'resource_id' => (string) $product->id, ]; } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php new file mode 100644 index 00000000..2b6ed0e3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php @@ -0,0 +1,135 @@ + 42, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]; + + (new SeoProductCacheListener)->onProductCreate($product); + + $row = SitemapUrl::where('resource_type', 'product')->where('resource_id', '42')->first(); + $this->assertNotNull($row, '전시 상품은 사이트맵에 색인돼야 합니다.'); + $this->assertStringEndsWith('/products/42', $row->loc); + $this->assertSame('sirsoft-ecommerce', $row->contributor); + + Bus::assertDispatched(GenerateSitemapJob::class); + } + + /** + * 숨김 상품으로 수정 시 사이트맵 색인이 제거(remove)된다. + */ + public function test_hidden_product_update_removes_from_sitemap(): void + { + // 먼저 전시로 색인 + (new SeoProductCacheListener)->onProductCreate( + (object) ['id' => 7, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()] + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '7')->count()); + + // 숨김으로 전환 + (new SeoProductCacheListener)->onProductUpdate( + (object) ['id' => 7, 'display_status' => ProductDisplayStatus::HIDDEN, 'updated_at' => now()] + ); + + $this->assertSame(0, SitemapUrl::where('resource_id', '7')->count(), '숨김 상품은 색인에서 제거돼야 합니다.'); + } + + /** + * 상품 삭제 시 사이트맵 색인이 제거된다. + */ + public function test_product_delete_removes_from_sitemap(): void + { + (new SeoProductCacheListener)->onProductCreate( + (object) ['id' => 9, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()] + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '9')->count()); + + (new SeoProductCacheListener)->onProductDelete((object) ['id' => 9]); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'product')->where('resource_id', '9')->count()); + } + + /** + * 활성 카테고리 변경 시 색인, 비활성 시 색인 제거. + */ + public function test_category_visibility_toggles_index(): void + { + $listener = new SeoCategoryCacheListener; + + $listener->onCategoryChange((object) ['id' => 3, 'slug' => 'shoes', 'is_active' => true, 'updated_at' => now()]); + $row = SitemapUrl::where('resource_type', 'category')->where('resource_id', '3')->first(); + $this->assertNotNull($row); + $this->assertStringContainsString('/category/shoes', $row->loc); + + $listener->onCategoryChange((object) ['id' => 3, 'slug' => 'shoes', 'is_active' => false, 'updated_at' => now()]); + $this->assertSame(0, SitemapUrl::where('resource_type', 'category')->where('resource_id', '3')->count()); + } + + /** + * 카테고리 삭제(ID 전달) 시 색인이 제거된다. + */ + public function test_category_delete_removes_index(): void + { + $listener = new SeoCategoryCacheListener; + $listener->onCategoryChange((object) ['id' => 4, 'slug' => 'bags', 'is_active' => true, 'updated_at' => now()]); + $this->assertSame(1, SitemapUrl::where('resource_type', 'category')->where('resource_id', '4')->count()); + + // after_delete 훅은 모델이 아닌 ID(int)를 전달 + $listener->onCategoryDelete(4); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'category')->where('resource_id', '4')->count()); + } + + /** + * 'SEO 제공 페이지(상품 상세)' 토글이 OFF 면 전시 상품이어도 색인이 제거된다. + * + * 리스너의 공개상태 판정은 전시 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치). + */ + public function test_product_detail_toggle_off_removes_index_even_when_visible(): void + { + $listener = new SeoProductCacheListener; + + // 토글 ON 상태로 먼저 색인 + $listener->onProductCreate((object) ['id' => 50, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]); + $this->assertSame(1, SitemapUrl::where('resource_id', '50')->count()); + + // 토글 OFF → 전시 상태여도 색인 제거 + Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', false); + $listener->onProductUpdate((object) ['id' => 50, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]); + + $this->assertSame(0, SitemapUrl::where('resource_id', '50')->count(), 'seo_product_detail OFF 면 전시 상품도 색인에서 제거돼야 합니다.'); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php index 843b2c14..85af9afc 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php @@ -2,8 +2,12 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; +use Mockery; use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener; use Tests\TestCase; @@ -19,9 +23,20 @@ class SeoCategoryCacheListenerTest extends TestCase protected function setUp(): void { parent::setUp(); + + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoCategoryCacheListener; } + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + // ======================================== // getSubscribedHooks() 테스트 // ======================================== @@ -33,18 +48,22 @@ class SeoCategoryCacheListenerTest extends TestCase { $hooks = SeoCategoryCacheListener::getSubscribedHooks(); - $expectedHooks = [ + // create/update 는 onCategoryChange, delete 는 onCategoryDelete (모델 대신 ID 를 받으므로 분리) + $changeHooks = [ 'sirsoft-ecommerce.category.after_create', 'sirsoft-ecommerce.category.after_update', - 'sirsoft-ecommerce.category.after_delete', ]; - foreach ($expectedHooks as $hookName) { + foreach ($changeHooks as $hookName) { $this->assertArrayHasKey($hookName, $hooks); $this->assertEquals('onCategoryChange', $hooks[$hookName]['method']); $this->assertEquals(20, $hooks[$hookName]['priority']); } + $this->assertArrayHasKey('sirsoft-ecommerce.category.after_delete', $hooks); + $this->assertEquals('onCategoryDelete', $hooks['sirsoft-ecommerce.category.after_delete']['method']); + $this->assertEquals(20, $hooks['sirsoft-ecommerce.category.after_delete']['priority']); + $this->assertCount(3, $hooks); } @@ -110,6 +129,9 @@ class SeoCategoryCacheListenerTest extends TestCase // When $this->listener->onCategoryChange($category); + + // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨 + $this->addToAssertionCount(1); } /** @@ -135,6 +157,9 @@ class SeoCategoryCacheListenerTest extends TestCase // When $this->listener->onCategoryChange($categoryId); + + // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨 + $this->addToAssertionCount(1); } /** @@ -188,6 +213,9 @@ class SeoCategoryCacheListenerTest extends TestCase // When & Then: 예외가 전파되지 않음 $this->listener->onCategoryChange($category); + + // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨 + $this->addToAssertionCount(1); } // ======================================== diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php index bd6bc091..81f84892 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php @@ -2,8 +2,11 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; use Mockery; use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener; @@ -36,6 +39,10 @@ class SeoProductCacheListenerTest extends TestCase $this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class); $this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock); + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoProductCacheListener; } diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index b2367b01..21db7ade 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 사이트맵에 담을 페이지 수의 안전 상한을 지원합니다. 상한을 설정하면 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. +- 페이지를 발행/미발행하거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 페이지가 많은 사이트에서도 빠르게 최신 상태가 유지됩니다. ### Changed diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php index 41d4d060..3fa39300 100644 --- a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php +++ b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php @@ -4,7 +4,9 @@ namespace Modules\Sirsoft\Page\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; +use App\Seo\SitemapIndexer; use Illuminate\Support\Facades\Log; /** @@ -32,7 +34,7 @@ class SeoPageCacheListener implements HookListenerInterface 'priority' => 20, ], 'sirsoft-page.page.after_delete' => [ - 'method' => 'onPageChange', + 'method' => 'onPageDelete', 'priority' => 20, ], // 버전 복원도 콘텐츠(title/content/seo_meta) 변경이므로 수정과 동일하게 무효화 @@ -94,5 +96,60 @@ class SeoPageCacheListener implements HookListenerInterface 'page_id' => is_object($page) ? ($page->id ?? null) : null, ]); } + + $this->syncSitemapIndex($page, false); + } + + /** + * 페이지 삭제 시 SEO 캐시를 무효화하고 사이트맵 색인을 제거합니다. + * + * @param mixed ...$args 훅 인자 (첫 번째: Page 모델) + */ + public function onPageDelete(...$args): void + { + $this->onPageChange(...$args); + + $this->syncSitemapIndex($args[0] ?? null, true); + } + + /** + * 페이지의 사이트맵 색인을 증분 갱신합니다. + * + * 발행(published) 상태이면 색인(upsert), 미발행/삭제이면 색인 해제(remove)한 뒤 + * 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 PageSitemapContributor 의 페이지 URL 규칙과 일치해야 합니다. + * + * @param mixed $page Page 모델 + * @param bool $deleted 삭제 이벤트 여부 + */ + private function syncSitemapIndex(mixed $page, bool $deleted): void + { + if (! is_object($page) || ! isset($page->id) || ! isset($page->slug)) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = ! $deleted && (bool) ($page->published ?? false); + + if ($visible) { + $indexer->indexResource('page', $page->id, 'sirsoft-page', [[ + 'url' => "/page/{$page->slug}", + 'lastmod' => $page->updated_at?->toW3cString(), + 'changefreq' => 'monthly', + 'priority' => 0.5, + ]]); + } else { + $indexer->deindexResource('page', $page->id); + } + + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Page sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'page_id' => is_object($page) ? ($page->id ?? null) : null, + ]); + } } } diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php index 6bdca007..bdf87c17 100644 --- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php +++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php @@ -62,6 +62,8 @@ class PageSitemapContributor extends AbstractSitemapContributor 'lastmod' => $page->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, + 'resource_type' => 'page', + 'resource_id' => (string) $page->id, ]; $emitted++; } diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php new file mode 100644 index 00000000..5ca854a2 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php @@ -0,0 +1,80 @@ +app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class)); + Bus::fake([GenerateSitemapJob::class]); + + $this->listener = new SeoPageCacheListener; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * 발행된 페이지 생성/수정 시 색인(append)된다. + */ + public function test_published_page_is_indexed(): void + { + $page = (object) ['id' => 200, 'slug' => 'about', 'published' => true, 'updated_at' => now()]; + + $this->listener->onPageChange($page); + + $row = SitemapUrl::where('resource_type', 'page')->where('resource_id', '200')->first(); + $this->assertNotNull($row, '발행 페이지는 색인돼야 합니다.'); + $this->assertStringEndsWith('/page/about', $row->loc); + Bus::assertDispatched(GenerateSitemapJob::class); + } + + /** + * 미발행 페이지는 색인되지 않고, 발행→미발행 전환 시 색인이 제거된다. + */ + public function test_unpublished_page_removes_index(): void + { + $this->listener->onPageChange((object) ['id' => 201, 'slug' => 'draft', 'published' => true, 'updated_at' => now()]); + $this->assertSame(1, SitemapUrl::where('resource_id', '201')->count()); + + $this->listener->onPageChange((object) ['id' => 201, 'slug' => 'draft', 'published' => false, 'updated_at' => now()]); + $this->assertSame(0, SitemapUrl::where('resource_id', '201')->count(), '미발행 전환 시 색인이 제거돼야 합니다.'); + } + + /** + * 페이지 삭제 시 색인이 제거된다. + */ + public function test_page_delete_removes_index(): void + { + $this->listener->onPageChange((object) ['id' => 202, 'slug' => 'gone', 'published' => true, 'updated_at' => now()]); + $this->assertSame(1, SitemapUrl::where('resource_id', '202')->count()); + + $this->listener->onPageDelete((object) ['id' => 202, 'slug' => 'gone', 'published' => true]); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'page')->where('resource_id', '202')->count()); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php index 1db416af..3db014c0 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php @@ -3,8 +3,12 @@ namespace Modules\Sirsoft\Page\Tests\Unit\Listeners; use App\Contracts\Extension\CacheInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheManager; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; +use Mockery; use Modules\Sirsoft\Page\Listeners\SeoPageCacheListener; use Modules\Sirsoft\Page\Tests\ModuleTestCase; @@ -25,9 +29,20 @@ class SeoPageCacheListenerTest extends ModuleTestCase parent::setUp(); $this->cache = $this->app->make(SeoCacheManagerInterface::class); + + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoPageCacheListener; } + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + // ─── 훅 구독 등록 ────────────────────────────────────── /** @@ -45,7 +60,8 @@ class SeoPageCacheListenerTest extends ModuleTestCase $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_create']['method']); $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_update']['method']); - $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_delete']['method']); + // delete 는 모델 상태와 무관하게 색인을 제거해야 하므로 전용 메서드로 분리 + $this->assertEquals('onPageDelete', $hooks['sirsoft-page.page.after_delete']['method']); $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_restore']['method']); $this->assertEquals(20, $hooks['sirsoft-page.page.after_create']['priority']); diff --git a/tests/Feature/Seo/SitemapBackfillTest.php b/tests/Feature/Seo/SitemapBackfillTest.php new file mode 100644 index 00000000..a7461337 --- /dev/null +++ b/tests/Feature/Seo/SitemapBackfillTest.php @@ -0,0 +1,90 @@ +app->make(SitemapGenerator::class)->registerContributor($this->fakeContributor()); + } + + /** + * resource identity 를 emit 하는 가짜 기여자를 만듭니다. + * + * @return AbstractSitemapContributor 가짜 기여자 + */ + private function fakeContributor(): AbstractSitemapContributor + { + return new class extends AbstractSitemapContributor + { + public function getIdentifier(): string + { + return 'fake-contributor'; + } + + public function getUrlsLazy(): iterable + { + yield ['url' => '/x/1', 'resource_type' => 'thing', 'resource_id' => '1', 'changefreq' => 'daily', 'priority' => 0.7]; + yield ['url' => '/x/2', 'resource_type' => 'thing', 'resource_id' => '2', 'changefreq' => 'daily', 'priority' => 0.7]; + } + }; + } + + /** + * --rebuild --sync: 기여자로부터 저장소를 백필하고 파일을 커밋한다. + */ + public function test_rebuild_backfills_store_and_commits_files(): void + { + $this->artisan('seo:generate-sitemap', ['--rebuild' => true, '--sync' => true]) + ->assertExitCode(0); + + $rows = SitemapUrl::where('contributor', 'fake-contributor')->pluck('resource_id')->sort()->values()->all(); + $this->assertSame(['1', '2'], $rows, '기여자 URL 이 저장소에 백필돼야 합니다.'); + + $store = new SitemapFileStore(new CoreStorageDriver('local')); + $this->assertTrue($store->exists(), '백필 후 sitemap 파일 세트가 커밋돼야 합니다.'); + } + + /** + * --mode=auto --sync: 빈 저장소에서 full 로 자동 백필한다. + */ + public function test_auto_mode_backfills_when_store_empty(): void + { + $this->assertSame(0, SitemapUrl::count()); + + $this->artisan('seo:generate-sitemap', ['--mode' => 'auto', '--sync' => true]) + ->assertExitCode(0); + + $this->assertSame(2, SitemapUrl::where('contributor', 'fake-contributor')->count(), '빈 저장소에서 auto 는 full 백필이어야 합니다.'); + } +} diff --git a/tests/Feature/Seo/SitemapIndexerTest.php b/tests/Feature/Seo/SitemapIndexerTest.php new file mode 100644 index 00000000..717623e7 --- /dev/null +++ b/tests/Feature/Seo/SitemapIndexerTest.php @@ -0,0 +1,112 @@ +indexer = $this->app->make(SitemapIndexer::class); + HookManager::clearFilter(SitemapIndexer::COLLECT_FILTER); + } + + /** + * 테스트 정리 - filter 훅 누출 방지 + */ + protected function tearDown(): void + { + HookManager::clearFilter(SitemapIndexer::COLLECT_FILTER); + + parent::tearDown(); + } + + /** + * indexResource: 공개 리소스를 색인하며 url 을 loc 으로 정규화한다. + */ + public function test_index_resource_upserts_and_normalizes_url(): void + { + $this->indexer->indexResource('product', 7, 'sirsoft-ecommerce', [[ + 'url' => '/shop/products/7', + 'changefreq' => 'weekly', + 'priority' => 0.8, + ]]); + + $row = SitemapUrl::where('resource_type', 'product')->where('resource_id', '7')->first(); + + $this->assertNotNull($row); + $this->assertSame(url('/shop/products/7'), $row->loc, 'url 은 절대 loc 으로 정규화돼야 합니다.'); + $this->assertSame('sirsoft-ecommerce', $row->contributor); + } + + /** + * deindexResource: 색인을 제거한다. + */ + public function test_deindex_resource_removes_index(): void + { + $this->indexer->indexResource('page', 3, 'sirsoft-page', [['url' => '/page/about']]); + $this->assertSame(1, SitemapUrl::where('resource_type', 'page')->where('resource_id', '3')->count()); + + $this->indexer->deindexResource('page', 3); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'page')->where('resource_id', '3')->count()); + } + + /** + * collect_for_resource filter 훅으로 제3자가 항목을 가공할 수 있다. + */ + public function test_collect_filter_hook_can_transform_entries(): void + { + HookManager::addFilter(SitemapIndexer::COLLECT_FILTER, function (array $entries) { + $entries[] = ['url' => '/extra']; + + return $entries; + }); + + $this->indexer->indexResource('product', 8, 'sirsoft-ecommerce', [['url' => '/shop/products/8']]); + + $locs = SitemapUrl::where('resource_type', 'product')->where('resource_id', '8')->pluck('loc')->all(); + + $this->assertContains(url('/shop/products/8'), $locs); + $this->assertContains(url('/extra'), $locs, 'filter 훅이 추가한 항목이 색인돼야 합니다.'); + } + + /** + * 필터가 항목을 모두 제거하면 색인도 제거된다(정합 유지). + */ + public function test_filter_removing_all_entries_deindexes(): void + { + $this->indexer->indexResource('product', 9, 'sirsoft-ecommerce', [['url' => '/shop/products/9']]); + $this->assertSame(1, SitemapUrl::where('resource_id', '9')->count()); + + HookManager::addFilter(SitemapIndexer::COLLECT_FILTER, fn () => []); + + $this->indexer->indexResource('product', 9, 'sirsoft-ecommerce', [['url' => '/shop/products/9']]); + + $this->assertSame(0, SitemapUrl::where('resource_id', '9')->count(), '항목이 모두 제거되면 색인이 제거돼야 합니다.'); + } +} diff --git a/tests/Feature/Seo/SitemapManagerModeTest.php b/tests/Feature/Seo/SitemapManagerModeTest.php new file mode 100644 index 00000000..d039bfd3 --- /dev/null +++ b/tests/Feature/Seo/SitemapManagerModeTest.php @@ -0,0 +1,187 @@ +storage = new CoreStorageDriver('local'); + $this->repository = $this->app->make(SitemapUrlRepositoryInterface::class); + } + + /** + * 호출 횟수를 세는 가짜 기여자를 만듭니다. + * + * @param array $entries emit 항목 + * @return AbstractSitemapContributor 카운터가 달린 기여자 + */ + private function countingContributor(array $entries): AbstractSitemapContributor + { + return new class($entries) extends AbstractSitemapContributor + { + public int $drainCount = 0; + + /** + * @param array> $entries emit 항목 + */ + public function __construct(private array $entries) {} + + public function getIdentifier(): string + { + return 'fake-contributor'; + } + + public function getUrlsLazy(): iterable + { + $this->drainCount++; + yield from $this->entries; + } + }; + } + + /** + * 매니저를 구성합니다 (기여자 등록 포함). + * + * @param AbstractSitemapContributor $contributor 등록할 기여자 + * @return SitemapManager 매니저 + */ + private function makeManager(AbstractSitemapContributor $contributor): SitemapManager + { + $generator = $this->app->make(SitemapGenerator::class); + $generator->registerContributor($contributor); + + return new SitemapManager( + $generator, + Mockery::spy(CacheInterface::class), + Mockery::spy(ConfigRepositoryInterface::class), + $this->storage, + $this->repository, + ); + } + + /** + * 커밋된 첫 자식 sitemap 의 XML 본문을 반환합니다. + * + * @return string 자식 XML + */ + private function firstChildXml(): string + { + return (string) $this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::childFilename(1)); + } + + /** + * full: 저장소가 이미 채워져 있어도 기여자를 스트리밍해 전량 대체한다. + */ + public function test_full_mode_rebuilds_store_from_contributors(): void + { + // 옛(stale) 저장소 상태 — 전량 대체로 사라져야 한다 + $this->repository->replaceAllForContributor('fake-contributor', [ + ['loc' => url('/stale'), 'resource_type' => 'product', 'resource_id' => '999'], + ]); + + $contributor = $this->countingContributor([ + ['url' => '/fresh', 'resource_type' => 'product', 'resource_id' => '1'], + ]); + + $this->makeManager($contributor)->regenerate(SitemapGenerationMode::Full); + + $this->assertSame(1, $contributor->drainCount, 'full 은 기여자를 드레인해야 합니다.'); + $locs = SitemapUrl::where('contributor', 'fake-contributor')->pluck('loc')->all(); + $this->assertSame([url('/fresh')], $locs, 'full 은 저장소를 전량 대체해야 합니다.'); + $this->assertStringContainsString(url('/fresh'), $this->firstChildXml()); + } + + /** + * auto: 저장소가 비어 있으면 full 로 동작(기여자 드레인 + 저장소 채움). + */ + public function test_auto_mode_is_full_when_store_empty(): void + { + $contributor = $this->countingContributor([ + ['url' => '/a', 'resource_type' => 'product', 'resource_id' => '1'], + ]); + + $this->makeManager($contributor)->regenerate(SitemapGenerationMode::Auto); + + $this->assertSame(1, $contributor->drainCount, '빈 저장소에서 auto 는 full 이어야 합니다.'); + $this->assertSame(1, $this->repository->countVisible()); + } + + /** + * auto: 저장소가 채워져 있으면 incremental 로 동작(기여자 드레인 없음). + */ + public function test_auto_mode_is_incremental_when_store_populated(): void + { + // 다른 경로(리스너)로 채워진 저장소 — auto 는 이를 재쿼리 없이 사용 + $this->repository->upsertForResource('page', '1', [['loc' => url('/page/seeded'), 'contributor' => 'sirsoft-page']]); + + $contributor = $this->countingContributor([ + ['url' => '/should-not-appear', 'resource_type' => 'product', 'resource_id' => '1'], + ]); + + $this->makeManager($contributor)->regenerate(SitemapGenerationMode::Auto); + + $this->assertSame(0, $contributor->drainCount, '채워진 저장소에서 auto 는 기여자를 재쿼리하면 안 됩니다.'); + + $xml = $this->firstChildXml(); + $this->assertStringContainsString(url('/page/seeded'), $xml); + $this->assertStringNotContainsString(url('/should-not-appear'), $xml); + } + + /** + * incremental: 기여자 재쿼리 없이 저장소 스트림만으로 파일을 재작성한다. + */ + public function test_incremental_mode_streams_store_without_domain_requery(): void + { + $this->repository->upsertForResource('board_post', '1', [['loc' => url('/board/a/1'), 'contributor' => 'sirsoft-board']]); + + $contributor = $this->countingContributor([ + ['url' => '/should-not-appear', 'resource_type' => 'product', 'resource_id' => '1'], + ]); + + $this->makeManager($contributor)->regenerate(SitemapGenerationMode::Incremental); + + $this->assertSame(0, $contributor->drainCount, 'incremental 은 기여자를 재쿼리하면 안 됩니다.'); + $this->assertStringContainsString(url('/board/a/1'), $this->firstChildXml()); + } +} diff --git a/tests/Feature/Seo/SitemapUrlRepositoryTest.php b/tests/Feature/Seo/SitemapUrlRepositoryTest.php new file mode 100644 index 00000000..dff42503 --- /dev/null +++ b/tests/Feature/Seo/SitemapUrlRepositoryTest.php @@ -0,0 +1,176 @@ +repository = $this->app->make(SitemapUrlRepositoryInterface::class); + } + + /** + * upsertForResource: 항목을 삽입하고 반복 호출해도 테이블 내용이 동일하다(멱등). + */ + public function test_upsert_for_resource_is_idempotent(): void + { + $entries = [[ + 'loc' => 'https://example.com/shop/products/1', + 'contributor' => 'sirsoft-ecommerce', + 'lastmod' => '2026-07-18T00:00:00+00:00', + 'changefreq' => 'weekly', + 'priority' => 0.8, + ]]; + + $this->repository->upsertForResource('product', '1', $entries); + $this->repository->upsertForResource('product', '1', $entries); + + $rows = SitemapUrl::where('resource_type', 'product')->where('resource_id', '1')->get(); + + $this->assertCount(1, $rows, '같은 입력 반복 호출 시 행이 중복되면 안 됩니다.'); + $this->assertSame('https://example.com/shop/products/1', $rows[0]->loc); + $this->assertSame('sirsoft-ecommerce', $rows[0]->contributor); + $this->assertSame(0.8, $rows[0]->priority); + $this->assertTrue($rows[0]->is_visible); + } + + /** + * upsertForResource: loc(슬러그) 변경 시 옛 loc 행이 남지 않는다. + */ + public function test_upsert_for_resource_absorbs_loc_change(): void + { + $this->repository->upsertForResource('category', '5', [[ + 'loc' => 'https://example.com/shop/category/old-slug', + 'contributor' => 'sirsoft-ecommerce', + ]]); + + $this->repository->upsertForResource('category', '5', [[ + 'loc' => 'https://example.com/shop/category/new-slug', + 'contributor' => 'sirsoft-ecommerce', + ]]); + + $locs = SitemapUrl::where('resource_type', 'category')->where('resource_id', '5')->pluck('loc'); + + $this->assertSame(['https://example.com/shop/category/new-slug'], $locs->all()); + } + + /** + * removeForResource: 해당 리소스 행만 제거한다. + */ + public function test_remove_for_resource_deletes_only_that_resource(): void + { + $this->repository->upsertForResource('product', '1', [['loc' => 'https://example.com/p/1', 'contributor' => 'sirsoft-ecommerce']]); + $this->repository->upsertForResource('product', '2', [['loc' => 'https://example.com/p/2', 'contributor' => 'sirsoft-ecommerce']]); + + $this->repository->removeForResource('product', '1'); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'product')->where('resource_id', '1')->count()); + $this->assertSame(1, SitemapUrl::where('resource_type', 'product')->where('resource_id', '2')->count()); + } + + /** + * streamVisible: is_visible=true 행을 id 순으로, contributor 스코핑하여 반환한다. + */ + public function test_stream_visible_orders_by_id_and_scopes_by_contributor(): void + { + $this->repository->upsertForResource('board_post', '1', [['loc' => 'https://example.com/board/a/1', 'contributor' => 'sirsoft-board']]); + $this->repository->upsertForResource('product', '1', [['loc' => 'https://example.com/shop/products/1', 'contributor' => 'sirsoft-ecommerce']]); + $this->repository->upsertForResource('board_post', '2', [['loc' => 'https://example.com/board/a/2', 'contributor' => 'sirsoft-board']]); + + $all = collect($this->repository->streamVisible())->map(fn ($r) => $r->loc)->all(); + $boardOnly = collect($this->repository->streamVisible('sirsoft-board'))->map(fn ($r) => $r->loc)->all(); + + $this->assertSame([ + 'https://example.com/board/a/1', + 'https://example.com/shop/products/1', + 'https://example.com/board/a/2', + ], $all, 'id(삽입) 순서를 유지해야 합니다.'); + + $this->assertSame([ + 'https://example.com/board/a/1', + 'https://example.com/board/a/2', + ], $boardOnly, 'contributor 스코핑이 적용돼야 합니다.'); + } + + /** + * countVisible: 공개 행 개수를 센다. + */ + public function test_count_visible_counts_rows(): void + { + $this->assertSame(0, $this->repository->countVisible()); + + $this->repository->upsertForResource('page', '1', [['loc' => 'https://example.com/page/a', 'contributor' => 'sirsoft-page']]); + $this->repository->upsertForResource('page', '2', [['loc' => 'https://example.com/page/b', 'contributor' => 'sirsoft-page']]); + + $this->assertSame(2, $this->repository->countVisible()); + } + + /** + * replaceAllForContributor: 기여자 스코프를 전량 대체하고 타 기여자는 건드리지 않는다. + */ + public function test_replace_all_for_contributor_replaces_scope_only(): void + { + // 다른 기여자(page) 는 보존돼야 한다 + $this->repository->upsertForResource('page', '1', [['loc' => 'https://example.com/page/a', 'contributor' => 'sirsoft-page']]); + + // board 기여자 1차 적재 + $this->repository->replaceAllForContributor('sirsoft-board', [ + ['loc' => 'https://example.com/board/a', 'resource_type' => 'board', 'resource_id' => '1'], + ['loc' => 'https://example.com/board/a/1', 'resource_type' => 'board_post', 'resource_id' => '1'], + ]); + + // board 기여자 2차 전량 대체 (1건으로 축소) + $this->repository->replaceAllForContributor('sirsoft-board', [ + ['loc' => 'https://example.com/board/b', 'resource_type' => 'board', 'resource_id' => '2'], + ]); + + $board = SitemapUrl::where('contributor', 'sirsoft-board')->pluck('loc')->all(); + $page = SitemapUrl::where('contributor', 'sirsoft-page')->pluck('loc')->all(); + + $this->assertSame(['https://example.com/board/b'], $board, 'board 스코프는 전량 대체돼야 합니다.'); + $this->assertSame(['https://example.com/page/a'], $page, '다른 기여자 행은 보존돼야 합니다.'); + } + + /** + * 긴 loc(2000자) 도 loc_hash 로 안전 저장 및 식별된다 (MySQL 키 길이 회피). + */ + public function test_long_loc_is_stored_via_hash_identity(): void + { + $longLoc = 'https://example.com/'.str_repeat('a', 1990); + + $this->repository->upsertForResource('product', '9', [['loc' => $longLoc, 'contributor' => 'sirsoft-ecommerce']]); + // 동일 loc 재호출 → 멱등 (해시 identity 로 대체) + $this->repository->upsertForResource('product', '9', [['loc' => $longLoc, 'contributor' => 'sirsoft-ecommerce']]); + + $rows = SitemapUrl::where('resource_type', 'product')->where('resource_id', '9')->get(); + + $this->assertCount(1, $rows); + $this->assertSame($longLoc, $rows[0]->loc); + $this->assertSame(hash('sha256', $longLoc), $rows[0]->loc_hash); + } +} diff --git a/tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php b/tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php index 148fe4db..8de1f3ee 100644 --- a/tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php +++ b/tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Console\Commands; +use App\Enums\SitemapGenerationMode; use App\Jobs\GenerateSitemapJob; use Illuminate\Console\Command; use Illuminate\Support\Facades\Bus; @@ -12,7 +13,8 @@ use Tests\TestCase; /** * SeoGenerateSitemapCommand 유닛 테스트 * - * seo:generate-sitemap 커맨드의 큐 드라이버 분기, 강제 동기, 출력 메시지를 검증합니다. + * seo:generate-sitemap 커맨드의 큐 드라이버 분기, 강제 동기, 모드 옵션(--mode/--rebuild), + * 출력 메시지를 검증합니다. */ class SeoGenerateSitemapCommandTest extends TestCase { @@ -31,10 +33,10 @@ class SeoGenerateSitemapCommandTest extends TestCase Bus::fake([GenerateSitemapJob::class]); $this->artisan('seo:generate-sitemap') - ->expectsOutput('Sitemap 생성이 큐에 디스패치되었습니다.') + ->expectsOutput('Sitemap 생성이 큐에 디스패치되었습니다. (모드: auto)') ->assertExitCode(Command::SUCCESS); - Bus::assertDispatched(GenerateSitemapJob::class); + Bus::assertDispatched(GenerateSitemapJob::class, fn (GenerateSitemapJob $job) => $job->mode === SitemapGenerationMode::Auto); Bus::assertNotDispatchedSync(GenerateSitemapJob::class); } @@ -47,7 +49,7 @@ class SeoGenerateSitemapCommandTest extends TestCase Bus::fake([GenerateSitemapJob::class]); $this->artisan('seo:generate-sitemap') - ->expectsOutput('Sitemap이 생성되었습니다.') + ->expectsOutput('Sitemap이 생성되었습니다. (모드: auto)') ->assertExitCode(Command::SUCCESS); Bus::assertDispatchedSync(GenerateSitemapJob::class); @@ -62,9 +64,53 @@ class SeoGenerateSitemapCommandTest extends TestCase Bus::fake([GenerateSitemapJob::class]); $this->artisan('seo:generate-sitemap', ['--sync' => true]) - ->expectsOutput('Sitemap이 생성되었습니다.') + ->expectsOutput('Sitemap이 생성되었습니다. (모드: auto)') ->assertExitCode(Command::SUCCESS); Bus::assertDispatchedSync(GenerateSitemapJob::class); } + + /** + * --mode 옵션이 잡에 그대로 전달됩니다. + */ + public function test_command_passes_mode_option_to_job(): void + { + Config::set('queue.default', 'database'); + Bus::fake([GenerateSitemapJob::class]); + + $this->artisan('seo:generate-sitemap', ['--mode' => 'incremental']) + ->expectsOutput('Sitemap 생성이 큐에 디스패치되었습니다. (모드: incremental)') + ->assertExitCode(Command::SUCCESS); + + Bus::assertDispatched(GenerateSitemapJob::class, fn (GenerateSitemapJob $job) => $job->mode === SitemapGenerationMode::Incremental); + } + + /** + * --rebuild 옵션은 --mode 보다 우선하며 full 로 강제합니다. + */ + public function test_rebuild_option_forces_full_mode(): void + { + Config::set('queue.default', 'database'); + Bus::fake([GenerateSitemapJob::class]); + + $this->artisan('seo:generate-sitemap', ['--rebuild' => true, '--mode' => 'incremental']) + ->expectsOutput('Sitemap 생성이 큐에 디스패치되었습니다. (모드: full)') + ->assertExitCode(Command::SUCCESS); + + Bus::assertDispatched(GenerateSitemapJob::class, fn (GenerateSitemapJob $job) => $job->mode === SitemapGenerationMode::Full); + } + + /** + * 잘못된 --mode 값은 INVALID 로 거부하고 잡을 디스패치하지 않습니다. + */ + public function test_invalid_mode_is_rejected(): void + { + Config::set('queue.default', 'database'); + Bus::fake([GenerateSitemapJob::class]); + + $this->artisan('seo:generate-sitemap', ['--mode' => 'bogus']) + ->assertExitCode(Command::INVALID); + + Bus::assertNotDispatched(GenerateSitemapJob::class); + } } diff --git a/tests/Unit/Seo/SitemapManagerTest.php b/tests/Unit/Seo/SitemapManagerTest.php index 952e6168..5673c09c 100644 --- a/tests/Unit/Seo/SitemapManagerTest.php +++ b/tests/Unit/Seo/SitemapManagerTest.php @@ -4,12 +4,14 @@ namespace Tests\Unit\Seo; use App\Contracts\Extension\CacheInterface; use App\Contracts\Repositories\ConfigRepositoryInterface; +use App\Contracts\Repositories\SitemapUrlRepositoryInterface; use App\Extension\HookManager; use App\Extension\Storage\CoreStorageDriver; use App\Seo\Contracts\SitemapContributorInterface; use App\Seo\SitemapFileStore; use App\Seo\SitemapGenerator; use App\Seo\SitemapManager; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Storage; use Mockery; @@ -31,6 +33,8 @@ use Tests\TestCase; */ class SitemapManagerTest extends TestCase { + use RefreshDatabase; + /** * 이 테스트가 등록·검증하는 sitemap 훅 목록 */ @@ -128,6 +132,7 @@ class SitemapManagerTest extends TestCase $cache ?? Mockery::spy(CacheInterface::class), $config ?? Mockery::spy(ConfigRepositoryInterface::class), $this->storage, + $this->app->make(SitemapUrlRepositoryInterface::class), ); } @@ -299,13 +304,14 @@ class SitemapManagerTest extends TestCase // 생성 도중 예외를 던지는 generator 로 실패를 강제한다. $generator = Mockery::mock(SitemapGenerator::class); - $generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('디스크 장애')); + $generator->shouldReceive('generateToWriterFromEntries')->andThrow(new \RuntimeException('디스크 장애')); $manager = new SitemapManager( $generator, Mockery::spy(CacheInterface::class), Mockery::spy(ConfigRepositoryInterface::class), $this->storage, + $this->incrementalRepository(), ); $result = $manager->regenerate(); @@ -329,18 +335,36 @@ class SitemapManagerTest extends TestCase $cache->shouldNotReceive('forget'); $generator = Mockery::mock(SitemapGenerator::class); - $generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('장애')); + $generator->shouldReceive('generateToWriterFromEntries')->andThrow(new \RuntimeException('장애')); $manager = new SitemapManager( $generator, $cache, Mockery::spy(ConfigRepositoryInterface::class), $this->storage, + $this->incrementalRepository(), ); $this->assertFalse($manager->regenerate()['success']); } + /** + * incremental 모드로 진입시키는(테이블이 비어있지 않은) mock 저장소를 만듭니다. + * + * countVisible()>0 → auto 가 incremental 로 해석되어 rebuildStore(전체 재적재)를 건너뛰고 + * 곧바로 generateToWriterFromEntries 로 진입합니다(실패 경로 단순화). + * + * @return SitemapUrlRepositoryInterface mock 저장소 + */ + private function incrementalRepository(): SitemapUrlRepositoryInterface + { + $repository = Mockery::mock(SitemapUrlRepositoryInterface::class); + $repository->shouldReceive('countVisible')->andReturn(1); + $repository->shouldReceive('streamVisible')->andReturn([]); + + return $repository; + } + /** * regenerate: seo.sitemap_urls_per_file 설정이 자식 분할에 반영된다 */