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
2026-07-18 16:19:49 +09:00
parent 1a6fca0d6b
commit 1629684156
34 changed files with 2027 additions and 23 deletions
@@ -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 를 생성합니다.
*
@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* sitemap_urls 테이블을 생성합니다.
*
* 사이트맵 URL 을 리소스 단위로 영속화하여 진정한 증분 갱신(공개=upsert / 비공개=remove)과
* 도메인 재쿼리 없는 파일 재작성을 가능하게 합니다. 리소스 변경 리스너가 이 테이블에
* 델타를 반영하고, 파일 생성은 이 테이블을 스트리밍하여 수행합니다.
*/
public function up(): void
{
if (Schema::hasTable('sitemap_urls')) {
return;
}
Schema::create('sitemap_urls', function (Blueprint $table) {
$table->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('최종 수정 시각 (<lastmod>)');
$table->string('changefreq', 16)->nullable()->comment('변경 빈도 (<changefreq>)');
$table->decimal('priority', 2, 1)->nullable()->comment('우선순위 0.0~1.0 (<priority>)');
$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');
}
}
};
@@ -9,6 +9,7 @@
### Added
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
### Changed
@@ -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,
]);
}
}
/**
@@ -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,
];
}
}
@@ -0,0 +1,144 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Seo;
use App\Jobs\GenerateSitemapJob;
use App\Models\SitemapUrl;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheRegenerator;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Config;
use Mockery;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* Board 리스너 사이트맵 증분 색인 테스트 (S4 ⑲)
*
* 검증 목적 (DoD: 공개→append / 비공개→remove):
* - 게시(Published) + 비밀글 아님 → 색인, 블라인드/비밀글/삭제 → 색인 제거
* - 활성 게시판 수정 → 색인, 비활성 → 색인 제거
*/
class BoardSitemapIndexTest extends ModuleTestCase
{
private SeoBoardCacheListener $listener;
protected function setUp(): void
{
parent::setUp();
// 캐시 무효화/재생성은 이 테스트 범위 밖 — spy 로 대체
$this->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 면 공개 게시글도 색인에서 제거돼야 합니다.');
}
}
@@ -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();
@@ -9,6 +9,7 @@
### Added
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
### Changed
@@ -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,
]);
}
}
}
@@ -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 캐시를 즉시 재생성합니다.
*
@@ -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,
];
}
}
@@ -0,0 +1,135 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Seo;
use App\Jobs\GenerateSitemapJob;
use App\Models\SitemapUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
use Tests\TestCase;
/**
* Ecommerce 리스너 사이트맵 증분 색인 테스트 (S4 ⑲)
*
* 검증 목적 (DoD: 공개→append / 비공개→remove):
* - 전시 상품 생성/수정 → 색인(append), 숨김/삭제 → 색인 제거
* - 활성 카테고리 변경 → 색인, 비활성/삭제 → 색인 제거
* - 리스너가 사이트맵 재생성 잡을 디스패치
*/
class EcommerceSitemapIndexTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Bus::fake([GenerateSitemapJob::class]);
}
/**
* 전시 상품 생성 시 사이트맵에 색인(append)되고 잡이 디스패치된다.
*/
public function test_visible_product_create_appends_to_sitemap(): void
{
$product = (object) ['id' => 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 면 전시 상품도 색인에서 제거돼야 합니다.');
}
}
@@ -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);
}
// ========================================
@@ -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;
}
@@ -9,6 +9,7 @@
### Added
- 사이트맵에 담을 페이지 수의 안전 상한을 지원합니다. 상한을 설정하면 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 페이지를 발행/미발행하거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 페이지가 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
### Changed
@@ -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,
]);
}
}
}
@@ -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++;
}
@@ -0,0 +1,80 @@
<?php
namespace Modules\Sirsoft\Page\Tests\Feature\Seo;
use App\Jobs\GenerateSitemapJob;
use App\Models\SitemapUrl;
use App\Seo\Contracts\SeoCacheManagerInterface;
use Illuminate\Support\Facades\Bus;
use Mockery;
use Modules\Sirsoft\Page\Listeners\SeoPageCacheListener;
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
/**
* Page 리스너 사이트맵 증분 색인 테스트 (S4 ⑲)
*
* 검증 목적 (DoD: 공개→append / 비공개→remove):
* - 발행(published=true) 페이지 → 색인, 미발행 → 색인 제거
* - 페이지 삭제 → 색인 제거
*/
class PageSitemapIndexTest extends ModuleTestCase
{
private SeoPageCacheListener $listener;
protected function setUp(): void
{
parent::setUp();
// 캐시 무효화는 이 테스트 범위 밖 — spy 로 대체
$this->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());
}
}
@@ -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']);
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Tests\Feature\Seo;
use App\Extension\Storage\CoreStorageDriver;
use App\Models\SitemapUrl;
use App\Seo\AbstractSitemapContributor;
use App\Seo\SitemapFileStore;
use App\Seo\SitemapGenerator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* 사이트맵 백필 통합 테스트 (S4 — 커맨드 → 잡 → 매니저 → 저장소)
*
* 검증 목적:
* - seo:generate-sitemap --rebuild --sync 가 기여자로부터 sitemap_urls 를 백필하고 파일을 커밋
* - 빈 저장소에서 --mode=auto 가 full 로 동작해 자동 백필
*/
class SitemapBackfillTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Config::set('app.locale', 'ko');
Config::set('app.supported_locales', ['ko']);
Config::set('g7_settings.core.seo.sitemap_enabled', true);
Config::set('queue.default', 'sync');
Storage::fake('local');
// 컨테이너 싱글톤 generator 에 가짜 기여자를 등록 → 잡이 해석하는 매니저가 이를 사용
$this->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 백필이어야 합니다.');
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace Tests\Feature\Seo;
use App\Extension\HookManager;
use App\Models\SitemapUrl;
use App\Seo\SitemapIndexer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* SitemapIndexer 통합 테스트 (S4 리스너 경유 색인기)
*
* 검증 목적:
* - indexResource 로 공개 리소스 색인(upsert)
* - deindexResource 로 비공개/삭제 리소스 색인 해제
* - url(상대) → loc(절대) 정규화
* - collect_for_resource filter 훅으로 제3자 항목 가공
* - 필터가 항목을 모두 제거하면 색인도 제거됨
*/
class SitemapIndexerTest extends TestCase
{
use RefreshDatabase;
private SitemapIndexer $indexer;
/**
* 테스트 초기화
*/
protected function setUp(): void
{
parent::setUp();
$this->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(), '항목이 모두 제거되면 색인이 제거돼야 합니다.');
}
}
@@ -0,0 +1,187 @@
<?php
namespace Tests\Feature\Seo;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
use App\Enums\SitemapGenerationMode;
use App\Extension\Storage\CoreStorageDriver;
use App\Models\SitemapUrl;
use App\Seo\AbstractSitemapContributor;
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;
use Tests\TestCase;
/**
* SitemapManager 재생성 모드 분기 테스트 (S4 ㉑)
*
* 검증 목적 (DoD: 모드 분기 green + 도메인 재쿼리 0):
* - full: 저장소가 채워져 있어도 기여자를 스트리밍해 전량 대체
* - auto: 저장소 비었으면 full(기여자 드레인), 채워졌으면 incremental(드레인 없음)
* - incremental: 기여자 재쿼리 없이 저장소 스트림만으로 파일 재작성
*/
class SitemapManagerModeTest extends TestCase
{
use RefreshDatabase;
private CoreStorageDriver $storage;
private SitemapUrlRepositoryInterface $repository;
/**
* 테스트 초기화
*/
protected function setUp(): void
{
parent::setUp();
Config::set('app.locale', 'ko');
Config::set('app.supported_locales', ['ko']);
Config::set('g7_settings.core.seo.sitemap_enabled', true);
Storage::fake('local');
$this->storage = new CoreStorageDriver('local');
$this->repository = $this->app->make(SitemapUrlRepositoryInterface::class);
}
/**
* 호출 횟수를 세는 가짜 기여자를 만듭니다.
*
* @param array<int, array{url: string, resource_type: string, resource_id: ?string}> $entries emit 항목
* @return AbstractSitemapContributor 카운터가 달린 기여자
*/
private function countingContributor(array $entries): AbstractSitemapContributor
{
return new class($entries) extends AbstractSitemapContributor
{
public int $drainCount = 0;
/**
* @param array<int, array<string, mixed>> $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());
}
}
@@ -0,0 +1,176 @@
<?php
namespace Tests\Feature\Seo;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
use App\Models\SitemapUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* SitemapUrlRepository 통합 테스트 (S4 증분 저장소)
*
* 검증 목적:
* - upsertForResource 멱등 (같은 입력 반복 호출 → 테이블 내용 동일)
* - loc 변경(슬러그 변경) 흡수 (옛 loc 행 잔존 금지)
* - removeForResource 로 리소스 단위 제거
* - streamVisible id 순서 + contributor 스코핑 + is_visible 필터
* - replaceAllForContributor 전량 대체 (타 기여자 미영향)
* - 긴 loc(2000자) 도 loc_hash unique 인덱스로 안전 저장
*/
class SitemapUrlRepositoryTest extends TestCase
{
use RefreshDatabase;
private SitemapUrlRepositoryInterface $repository;
/**
* 테스트 초기화 - 저장소를 컨테이너에서 해석합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}
@@ -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);
}
}
+26 -2
View File
@@ -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 설정이 자식 분할에 반영된다
*/