사이트맵을 리소스 단위로 영속화(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 옵션 추가
205 lines
7.5 KiB
PHP
205 lines
7.5 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
/**
|
|
* Sitemap 재생성 / 상태 조회 서비스.
|
|
*
|
|
* 큐 잡(GenerateSitemapJob)과 관리자 수동 트리거(SeoCacheController)에서 공통으로 사용합니다.
|
|
* Sitemap 을 SitemapWriter 로 스트리밍 생성하여 비공개 디스크에 분할 파일로 커밋하고,
|
|
* 마지막 업데이트 시각을 settings 의 seo 카테고리(sitemap_last_updated_at)에 기록합니다.
|
|
* 캐시에는 XML 본문이 아니라 메타데이터만 저장합니다.
|
|
*/
|
|
class SitemapManager
|
|
{
|
|
/**
|
|
* Sitemap 메타데이터 캐시 키
|
|
*/
|
|
public const META_CACHE_KEY = 'seo.sitemap.meta';
|
|
|
|
/**
|
|
* 구 버전 Sitemap XML 본문 캐시 키 (디스크 서빙 전환으로 폐기 — 잔여분 정리용)
|
|
*/
|
|
private const LEGACY_XML_CACHE_KEY = 'seo.sitemap';
|
|
|
|
public function __construct(
|
|
private SitemapGenerator $generator,
|
|
private CacheInterface $cache,
|
|
private ConfigRepositoryInterface $configRepository,
|
|
private StorageInterface $storage,
|
|
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(SitemapGenerationMode $mode = SitemapGenerationMode::Auto): array
|
|
{
|
|
$enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
|
|
if (! $enabled) {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'disabled',
|
|
'message' => __('seo.sitemap_disabled'),
|
|
];
|
|
}
|
|
|
|
HookManager::doAction('core.seo.sitemap.before_regenerate');
|
|
|
|
try {
|
|
$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();
|
|
|
|
// 디스크 서빙 전환 — 캐시에는 메타만 저장하고 구 XML 본문 캐시는 정리합니다.
|
|
$this->cache->forget(self::LEGACY_XML_CACHE_KEY);
|
|
$this->cache->put(self::META_CACHE_KEY, $meta, $ttl);
|
|
|
|
$lastUpdatedAt = $meta['generated_at'];
|
|
$this->updateLastUpdatedAt($lastUpdatedAt);
|
|
|
|
$result = [
|
|
'success' => true,
|
|
'status' => 'updated',
|
|
'message' => __('seo.sitemap_regenerated'),
|
|
'data' => [
|
|
'last_updated_at' => $lastUpdatedAt,
|
|
'size_bytes' => $meta['size_bytes'],
|
|
'url_count' => $meta['url_count'],
|
|
'child_count' => $meta['child_count'],
|
|
'ttl' => $ttl,
|
|
],
|
|
];
|
|
|
|
HookManager::doAction('core.seo.sitemap.after_regenerate', $result);
|
|
|
|
return $result;
|
|
} catch (\Throwable $e) {
|
|
Log::error('[SEO] Sitemap regeneration failed', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
$result = [
|
|
'success' => false,
|
|
'status' => 'failed',
|
|
'message' => __('seo.sitemap_generate_failed', ['error' => $e->getMessage()]),
|
|
];
|
|
|
|
HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result);
|
|
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 sitemap 의 메타데이터를 반환합니다.
|
|
*
|
|
* @return array{last_updated_at: ?string}
|
|
*/
|
|
public function getStatus(): array
|
|
{
|
|
$lastUpdatedAt = (string) g7_core_settings('seo.sitemap_last_updated_at', '');
|
|
|
|
return [
|
|
'last_updated_at' => $lastUpdatedAt !== '' ? $lastUpdatedAt : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 각 기여자를 스트리밍하여 사이트맵 저장소를 전량 대체합니다(전체 재생성).
|
|
*
|
|
* 기여자별로 격리하여, 한 기여자가 실패해도 나머지 기여자의 재적재는 계속됩니다.
|
|
*/
|
|
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 를 생성합니다.
|
|
*
|
|
* @return SitemapWriter 분할 기록기
|
|
*/
|
|
private function makeWriter(): SitemapWriter
|
|
{
|
|
return new SitemapWriter(
|
|
$this->storage,
|
|
SitemapXmlRenderer::fromConfig(),
|
|
(int) g7_core_settings('seo.sitemap_urls_per_file', SitemapWriter::HARD_URL_CAP),
|
|
(bool) g7_core_settings('seo.sitemap_gzip', false),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* settings 의 seo 카테고리에 sitemap_last_updated_at 을 기록합니다.
|
|
*
|
|
* @param string $iso8601 ISO8601 형식 타임스탬프
|
|
*/
|
|
private function updateLastUpdatedAt(string $iso8601): void
|
|
{
|
|
try {
|
|
$current = $this->configRepository->getCategory('seo');
|
|
$current['sitemap_last_updated_at'] = $iso8601;
|
|
$this->configRepository->saveCategory('seo', $current);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Sitemap last_updated_at 갱신 실패', ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
}
|