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

@@ -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']);