feat(seo): 사이트맵 증분 저장소 + 리소스 리스너 재배선

사이트맵을 리소스 단위로 영속화(sitemap_urls)해 진정한 증분 갱신과
도메인 재쿼리 없는 파일 재작성을 구현했다. 리소스 공개/비공개/삭제 시
해당 항목만 색인이 갱신되고, 관리자=full·스케줄러=auto·리소스 변경=증분으로 동작한다.

- sitemap_urls 마이그레이션 + SitemapUrl 모델. loc(2048) 를 unique 에 직접 넣으면
 InnoDB 키 길이를 초과하므로 loc_hash(sha256) 컬럼을 identity 로 사용
- SitemapUrlRepository(멱등 upsert / remove / 스트리밍) + SitemapIndexer
 (+ 제3자용 sitemap.index.collect_for_resource 필터 훅)
- 4 리스너 재배선: 공개→색인 / 비공개·삭제→색인 해제 + 재생성 잡 디바운스 디스패치.
 3 기여자가 resource_type/resource_id 를 함께 emit 해 전체·증분 identity 를 일치
- 재생성 모드를 SitemapGenerationMode Backed Enum 으로 통일(Command→Job→Manager)
- seo:generate-sitemap 에 --rebuild / --mode 옵션 추가
This commit is contained in:
HeuJung committed 2026-07-18 16:19:49 +09:00
1 parent 1a6fca0d6b
commit 1629684156
34 files changed
+2027 -23

No files matched your search

@@ -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'));
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\SitemapUrl;
/**
* 사이트맵 URL 저장소 인터페이스
*
* sitemap_urls 테이블에 대한 리소스 단위 증분 갱신과 스트리밍 조회를 제공합니다.
* 리스너는 SitemapIndexer 를 경유해 이 저장소를 사용하며(직접 Model/DB 접근 금지),
* 전체 재생성/증분 파일 작성은 이 저장소의 스트림을 소비합니다.
*/
interface SitemapUrlRepositoryInterface
{
/**
* 한 리소스의 사이트맵 URL 행을 현재 상태로 대체합니다(멱등).
*
* (resource_type, resource_id) 로 기존 행을 제거한 뒤 전달된 항목을 삽입하므로,
* 같은 입력으로 여러 번 호출해도 테이블 내용이 동일합니다(loc 변경/슬러그 변경 흡수).
*
* @param string $type 리소스 유형
* @param string $id 리소스 PK (문자열)
* @param array<int, array{loc: string, contributor: string, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $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<int, SitemapUrl> URL 순회자
*/
public function streamVisible(?string $contributor = null): iterable;
/**
* 공개(is_visible) URL 총 개수를 반환합니다.
*
* @return int 공개 URL 수
*/
public function countVisible(): int;
/**
* 한 기여자의 모든 사이트맵 URL 행을 전량 대체합니다(전체 재생성).
*
* 기여자 스코프로 기존 행을 제거한 뒤 스트림을 청크 삽입하여
* 대용량에서도 메모리를 유계로 유지합니다.
*
* @param string $contributor 기여자 식별자
* @param iterable<int, array{loc: string, resource_type?: string, resource_id?: mixed, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목 스트림
*/
public function replaceAllForContributor(string $contributor, iterable $entries): void;
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Enums;
/**
* 사이트맵 재생성 모드 Enum
*
* SitemapManager::regenerate 와 GenerateSitemapJob, seo:generate-sitemap 커맨드가
* 공유하는 재생성 모드 도메인입니다.
*
* - Full: 저장소 상태와 무관하게 각 기여자를 스트리밍해 sitemap_urls 를 전량 대체한 뒤 파일 재작성.
* - Auto: 저장소가 비어 있으면 Full, 아니면 Incremental 로 동작(스케줄러 기본).
* - Incremental: 기여자 재쿼리 없이 저장소의 현재 델타만으로 파일 재작성.
*/
enum SitemapGenerationMode: string
{
/**
* 전체 재생성 (관리자 수동 = 항상 Full)
*/
case Full = 'full';
/**
* 자동 판정 (빈 저장소=Full / 채워짐=Incremental)
*/
case Auto = 'auto';
/**
* 증분 재생성 (저장소 델타만 파일로 반영)
*/
case Incremental = 'incremental';
/**
* 저장소 상태를 반영해 실제 실행 모드(Full 또는 Incremental)로 해석합니다.
*
* Auto 는 저장소가 비어 있으면 Full, 아니면 Incremental 로 해석됩니다.
*
* @param int $visibleCount 저장소의 공개 URL 수
* @return self 실제 실행 모드 (Full | Incremental)
*/
public function resolve(int $visibleCount): self
{
return match ($this) {
self::Full => self::Full,
self::Incremental => self::Incremental,
self::Auto => $visibleCount === 0 ? self::Full : self::Incremental,
};
}
}
+9 -1
View File
@@ -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)');
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* 사이트맵 URL 영속 모델
*
* sitemap_urls 테이블의 한 행 = 사이트맵에 노출되는 하나의 리소스 URL.
* 리소스 단위 증분 갱신(공개=upsert / 비공개=remove)과 도메인 재쿼리 없는
* 파일 재작성의 저장소입니다. 데이터 접근은 SitemapUrlRepository 를 경유합니다.
*
* @property int $id
* @property string $resource_type
* @property string|null $resource_id
* @property string $loc
* @property string $loc_hash
* @property Carbon|null $lastmod
* @property string|null $changefreq
* @property float|null $priority
* @property string $contributor
* @property bool $is_visible
*/
class SitemapUrl extends Model
{
/**
* @var string 테이블명
*/
protected $table = 'sitemap_urls';
/**
* @var list<string> 대량 할당 가능 컬럼
*/
protected $fillable = [
'resource_type',
'resource_id',
'loc',
'loc_hash',
'lastmod',
'changefreq',
'priority',
'contributor',
'is_visible',
];
/**
* 속성 캐스팅을 반환합니다.
*
* @return array<string, string> 캐스팅 맵
*/
protected function casts(): array
{
return [
'lastmod' => 'datetime',
'priority' => 'float',
'is_visible' => 'boolean',
];
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Repositories;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
use App\Models\SitemapUrl;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 사이트맵 URL 저장소 구현체
*
* sitemap_urls 테이블을 통해 리소스 단위 증분 갱신과 스트리밍 조회를 수행합니다.
* loc 은 2048자라 unique 인덱스에 직접 넣지 못하므로 sha256 해시(loc_hash)를
* identity 로 사용합니다. lastmod 는 삽입 전 타임스탬프 문자열로 정규화합니다.
*/
class SitemapUrlRepository implements SitemapUrlRepositoryInterface
{
/**
* 청크 삽입 크기 (전체 재생성 시 유계 메모리 보장)
*/
private const INSERT_CHUNK = 1000;
/**
* 스트리밍 조회 청크 크기 (lazyById 키셋 페이징)
*/
private const STREAM_CHUNK = 1000;
/**
* {@inheritDoc}
*/
public function upsertForResource(string $type, string $id, array $entries): void
{
DB::transaction(function () use ($type, $id, $entries): void {
SitemapUrl::query()
->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<string, mixed> $entry URL 항목 (loc/lastmod/changefreq/priority)
* @param Carbon $now 타임스탬프 기준값
* @return array<string, mixed> 삽입용 행
*/
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;
}
}
}
+4
View File
@@ -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 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다.
+45
View File
@@ -76,6 +76,51 @@ class SitemapGenerator
return $writer->commit();
}
/**
* 미리 수집된 URL 항목(사이트맵 저장소 스트림 등)을 writer 로 스트리밍하여 파일을 생성합니다.
*
* 정적 라우트를 먼저 기록하고, 이어서 전달된 항목을 한 건씩 writer 에 넘깁니다.
* 증분/전체 재생성 모두 이 경로로 파일을 작성하며, 항목의 출처(도메인 재쿼리 vs 테이블 스트림)는
* 호출자가 결정합니다.
*
* @param SitemapWriter $writer 분할 기록기
* @param iterable<array{loc?: string, lastmod?: string, changefreq?: string, priority?: float}> $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<array<string, mixed>> 정규화된 URL 항목 순회자
*/
public function streamContributorEntries(SitemapContributorInterface $contributor): iterable
{
foreach ($this->drain($contributor) as $entry) {
yield $this->normalizeEntry($entry);
}
}
/**
* 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다.
*
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Seo;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
use App\Extension\HookManager;
/**
* 사이트맵 리소스 색인기
*
* 리소스 변경 리스너가 Model/DB 에 직접 접근하지 않고(계층 위반 방지) 이 서비스를 경유해
* 사이트맵 URL 델타를 반영합니다. 공개 리소스는 색인(upsert), 비공개/삭제 리소스는 색인 해제(remove)합니다.
*
* 리스너는 리소스의 URL 규칙(예: /board/{slug}/{id})으로 만든 항목을 넘기며,
* 제3자 확장은 filter 훅(sitemap.index.collect_for_resource)으로 항목을 가공/추가할 수 있습니다.
*/
class SitemapIndexer
{
/**
* 리소스 항목 가공 filter 훅 이름 (제3자 확장용)
*/
public const COLLECT_FILTER = 'sitemap.index.collect_for_resource';
/**
* SitemapIndexer 생성자
*
* @param SitemapUrlRepositoryInterface $repository 사이트맵 URL 저장소
*/
public function __construct(
private readonly SitemapUrlRepositoryInterface $repository,
) {}
/**
* 공개 리소스의 사이트맵 URL 을 색인합니다(멱등).
*
* @param string $type 리소스 유형 (예: product, board_post, page)
* @param string|int $id 리소스 PK
* @param string $contributor 기여자 식별자 (예: sirsoft-ecommerce)
* @param array<int, array{loc?: string, url?: string, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $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<string, mixed> $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;
}
}
+59 -2
View File
@@ -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<string, mixed>}
* 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<array{loc: string, lastmod: ?string, changefreq: ?string, priority: ?float}> 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 를 생성합니다.
*