feat(seo): 사이트맵 재생성 알림·폴링 폴백 + 코어 7.0.6 확정

이슈 후속(S7) 마감 + 릴리스 버전 확정.

기능(S7):
- 사이트맵 전체 재생성 완료/실패 시 실행 관리자에게 알림(수동 실행 한정).
 스케줄러·증분·봇 재생성은 알림 없음(triggered_by 부재 시 skip).
- 최종 실패 전용 알림 + 재생성 전용 큐 + WS 진행상황 payload 봉투 정정.
- Reverb OFF 폴링 폴백 경로 수정(실브라우저 폴링에서만 드러난 결함).

버전(D28 — 정정, D20 번복):
- 릴리스를 7.0.5→7.0.6(patch)로 확정. 7.1.0(minor)은 다른 브랜치에서 별도 진행.
- config/app.php + .env + README + INSTALL + CHANGELOG + 모듈 3 requires 를 7.0.6 로.
- D19 SEO 캐시 이행 업그레이드 스텝을 Upgrade_7_0_6 로 리네임.
 이유: 스텝 버전 게이팅이 version<=toVersion 이라, 7.1.0 이름이면
 7.0.5→7.0.6 업그레이드에서 이행이 실행되지 않는다.

검증: composer test-smoke OK(2·29) · 재생성 알림/업그레이드 이행/진행상황 테스트 green ·
version-sync/version-bump(new-bump)/extension-require green · audit 0 error.
This commit is contained in:
HeuJung
2026-07-19 18:57:51 +09:00
parent 3dd34104cc
commit 124433148c
35 changed files with 698 additions and 53 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.1.0
APP_VERSION=7.0.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.1.0
APP_VERSION=7.0.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+2 -1
View File
@@ -4,7 +4,7 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.1.0] - 2026-07-18
## [7.0.6] - 2026-07-19
### Added
@@ -14,6 +14,7 @@
- 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다.
- 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다.
- 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다.
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
### Fixed
+1 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.1.0 g7
# (필요 시) mv g7-7.0.6 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.1.0-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -12,6 +12,7 @@ use App\Seo\SitemapManager;
use App\Seo\SitemapProgress;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* SEO 캐시 관리 컨트롤러
@@ -115,7 +116,8 @@ class SeoCacheController extends AdminBaseController
}
// 관리자 수동 재생성은 항상 전체(Full) — 현 생성 상태와 무관하게 전량 재생성 (D7)
GenerateSitemapJob::dispatch(SitemapGenerationMode::Full);
// 실행한 관리자 ID 를 함께 실어, 완료/실패 시 그 관리자에게만 알림이 발송되게 한다.
GenerateSitemapJob::dispatch(SitemapGenerationMode::Full, Auth::id());
// 큐 대기 중에도 UI 가 'queued' 를 표시하도록 즉시 기록
$this->sitemapProgress->start(SitemapGenerationMode::Full->value);
+28 -2
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Enums\SitemapGenerationMode;
use App\Extension\HookManager;
use App\Seo\SitemapManager;
use App\Seo\SitemapProgress;
use Illuminate\Bus\Queueable;
@@ -26,6 +27,15 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var string 전용 큐 이름 — 대용량·장시간 사이트맵 생성을 default 큐에서 분리한다.
*
* 이 큐에는 반드시 워커를 1개만 배치한다(동시 중복 생성 방지). 그러면
* 긴 생성 작업이 default 큐의 방송/알림을 막지 않아 진행상황이 실시간으로 흐르고,
* 워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않는다.
*/
public const QUEUE = 'sitemap';
/**
* @var int 최대 재시도 횟수
*/
@@ -50,8 +60,15 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue
* GenerateSitemapJob 생성자
*
* @param SitemapGenerationMode $mode 재생성 모드 — SitemapManager 로 전달
* @param int|null $triggeredBy 관리자 수동 재생성을 실행한 사용자 ID(스케줄러/증분/봇 = null).
* 완료/실패 알림의 수신자(수동 실행자) 판정에 사용됩니다.
*/
public function __construct(public SitemapGenerationMode $mode = SitemapGenerationMode::Auto) {}
public function __construct(
public SitemapGenerationMode $mode = SitemapGenerationMode::Auto,
public ?int $triggeredBy = null,
) {
$this->onQueue(self::QUEUE);
}
/**
* 유니크 락 식별자를 반환합니다.
@@ -84,7 +101,7 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue
*/
public function handle(SitemapManager $manager): void
{
$result = $manager->regenerate($this->mode);
$result = $manager->regenerate($this->mode, $this->triggeredBy);
if (($result['status'] ?? null) === 'disabled') {
Log::info('[SEO] Sitemap generation skipped (disabled)');
@@ -112,6 +129,15 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue
// 잡 최종 실패(재시도 소진/타임아웃) 시 진행상황을 'failed' 로 고정 — 무한 running 방지
app(SitemapProgress::class)->fail($exception->getMessage());
// 최종 실패 전용 훅 — 매 시도마다 발화하는 core.seo.sitemap.after_regenerate_failed 와 달리
// 재시도가 모두 소진된 뒤 딱 한 번만 발화한다. 재시도 중 성공하면 발화하지 않으므로
// 실패 알림이 중복되거나(각 시도) 결국 성공했는데도 발송되는 일이 없다.
HookManager::doAction('core.seo.sitemap.regenerate_failed_final', [
'success' => false,
'status' => 'failed',
'message' => $exception->getMessage(),
], $this->triggeredBy);
Log::error('[SEO] Sitemap generation failed', [
'error' => $exception->getMessage(),
]);
@@ -0,0 +1,133 @@
<?php
namespace App\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* SEO 알림 데이터 필터 리스너
*
* notification_definitions 의 `core.seo.notification.extract_data` 필터를 처리하여
* 사이트맵 재생성 완료/실패 알림 발송에 필요한 데이터와 컨텍스트를 제공합니다.
*
* 정책: 사이트맵 재생성은 스케줄러/증분/봇에 의해서도 완료되므로, 관리자가 직접
* 실행(수동 재생성)한 경우에만 알림을 보냅니다. 수동 실행 여부는 훅에 함께 전달된
* 사용자 ID(triggered_by)로 판정하며, 없으면 context.skip 으로 발송을 중단시킵니다.
* 수신자 결정은 notification_definitions.recipients(trigger_user) 설정에 위임합니다.
*/
class SeoNotificationDataListener implements HookListenerInterface
{
/**
* 구독할 훅 목록을 반환합니다.
*
* @return array 훅 이름 → 메서드/우선순위/타입 매핑
*/
public static function getSubscribedHooks(): array
{
return [
'core.seo.notification.extract_data' => [
'method' => 'extractData',
'priority' => 20,
'type' => 'filter',
],
];
}
/**
* 훅 이벤트를 처리합니다(HookListenerInterface 필수 메서드).
*
* @param mixed ...$args 훅에서 전달된 인수들
*/
public function handle(...$args): void {}
/**
* 알림 유형에 따라 데이터와 컨텍스트를 추출합니다.
*
* @param array $default 기본 extract_data 구조
* @param string $type 알림 정의 유형
* @param array $args 훅에서 전달된 원본 인수 [$result, $triggeredBy]
* @return array{notifiable: null, notifiables: null, data: array, context: array}
*/
public function extractData(array $default, string $type, array $args): array
{
return match ($type) {
'sitemap_regenerated' => $this->extractRegenerated($args),
'sitemap_regenerate_failed' => $this->extractRegenerateFailed($args),
default => $default,
};
}
/**
* 사이트맵 재생성 완료 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$result, $triggeredBy]
* @return array
*/
private function extractRegenerated(array $args): array
{
$triggeredBy = $args[1] ?? null;
if (! is_int($triggeredBy)) {
return $this->skip();
}
$result = is_array($args[0] ?? null) ? $args[0] : [];
$data = $result['data'] ?? [];
$baseUrl = config('app.url');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'app_name' => config('app.name'),
'url_count' => (string) ($data['url_count'] ?? 0),
'child_count' => (string) ($data['child_count'] ?? 1),
'action_url' => "{$baseUrl}/admin/settings?tab=seo",
'site_url' => $baseUrl,
],
'context' => [
'trigger_user_id' => $triggeredBy,
],
];
}
/**
* 사이트맵 재생성 실패 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$result, $triggeredBy]
* @return array
*/
private function extractRegenerateFailed(array $args): array
{
$triggeredBy = $args[1] ?? null;
if (! is_int($triggeredBy)) {
return $this->skip();
}
$result = is_array($args[0] ?? null) ? $args[0] : [];
$baseUrl = config('app.url');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'app_name' => config('app.name'),
'error' => (string) ($result['message'] ?? ''),
'action_url' => "{$baseUrl}/admin/settings?tab=seo",
'site_url' => $baseUrl,
],
'context' => [
'trigger_user_id' => $triggeredBy,
],
];
}
/**
* 발송을 건너뛰도록 지시하는 결과를 반환합니다(비수동 재생성 = 유발 관리자 없음).
*
* @return array
*/
private function skip(): array
{
return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => ['skip' => true]];
}
}
+5 -3
View File
@@ -48,10 +48,12 @@ class SitemapManager
* - auto: 저장소가 비어 있으면 full, 아니면 incremental.
*
* @param SitemapGenerationMode $mode 재생성 모드
* @param int|null $triggeredBy 이 재생성을 유발한 관리자 사용자 ID(관리자 수동 재생성만 지정, 스케줄러/증분/봇 = null).
* 완료/실패 훅에 함께 전달되어 알림 발송 대상(수동 실행자) 판정에 쓰입니다.
* @return array{success: bool, status: string, message?: string, data?: array<string, mixed>}
* status: 'updated' | 'disabled' | 'failed'
*/
public function regenerate(SitemapGenerationMode $mode = SitemapGenerationMode::Auto): array
public function regenerate(SitemapGenerationMode $mode = SitemapGenerationMode::Auto, ?int $triggeredBy = null): array
{
$enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
if (! $enabled) {
@@ -103,7 +105,7 @@ class SitemapManager
$this->progress->complete($meta);
HookManager::doAction('core.seo.sitemap.after_regenerate', $result);
HookManager::doAction('core.seo.sitemap.after_regenerate', $result, $triggeredBy);
return $result;
} catch (\Throwable $e) {
@@ -119,7 +121,7 @@ class SitemapManager
'message' => __('seo.sitemap_generate_failed', ['error' => $e->getMessage()]),
];
HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result);
HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result, $triggeredBy);
return $result;
}
+28 -10
View File
@@ -135,7 +135,8 @@ final class SitemapProgress
$state['urls'] = $meta['url_count'] ?? ($state['urls'] ?? 0);
$state['message'] = null;
$this->persist($state, broadcast: true);
// 완료 시점엔 방금 커밋된 새 생성 시각을 실어 "마지막 생성"이 실시간 갱신되게 한다.
$this->persist($state, broadcast: true, lastUpdatedAt: $meta['generated_at'] ?? null);
}
/**
@@ -189,22 +190,39 @@ final class SitemapProgress
/**
* 진행상황을 캐시에 기록하고, 필요 시 관리자 채널로 방송합니다.
*
* 방송 payload 는 상태 API 응답의 `data` 형태(`progress` + `realtime_enabled`)와 동형입니다.
* websocket 데이터소스가 `target_source` 로 대상 소스를 통째로 교체하므로, 동형이어야
* 형제 필드(realtime_enabled)를 잃지 않고 UI 바인딩이 유지됩니다(D27).
* 방송 payload 는 상태 API 응답 봉투와 동형입니다 — 최상위 `data` 봉투 안에
* `progress`/`last_updated_at`/`realtime_enabled` 를 담습니다(대시보드 방송과 동일 규약).
* websocket 데이터소스가 `target_source` 로 대상 소스의 저장값을 통째 교체하므로, 봉투
* 형태여야 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지됩니다(D27).
*
* @param array<string, mixed> $state 진행상황 상태
* @param bool $broadcast 방송 여부(스로틀 판정 결과)
* @param string|null $lastUpdatedAt 이 방송에 실을 마지막 생성 시각(완료 시 새 시각, 그 외 직전 값)
*/
private function persist(array $state, bool $broadcast): void
private function persist(array $state, bool $broadcast, ?string $lastUpdatedAt = null): void
{
$this->cache->put(self::KEY, $state, self::TTL);
if ($broadcast) {
HookManager::broadcast(self::CHANNEL, self::EVENT, [
'realtime_enabled' => true,
'progress' => $state,
]);
if (! $broadcast) {
return;
}
// 미지정(진행 중 단계)이면 직전 완료 시각(settings)을 싣는다. target_source 가 대상 소스
// data 를 통째 교체하므로 매 방송마다 형제 필드를 온전히 실어야 "마지막 생성"이 유지된다.
if ($lastUpdatedAt === null) {
$stored = (string) g7_core_settings('seo.sitemap_last_updated_at', '');
$lastUpdatedAt = $stored !== '' ? $stored : null;
}
// 방송 payload 는 상태 API 응답 봉투와 동형이어야 한다(대시보드 방송과 동일 규약).
// 최상위 data 봉투 안에 progress/last_updated_at/realtime_enabled 를 담아야
// target_source 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지된다.
HookManager::broadcast(self::CHANNEL, self::EVENT, [
'data' => [
'last_updated_at' => $lastUpdatedAt,
'progress' => $state,
'realtime_enabled' => true,
],
]);
}
}
+1 -1
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.1.0'),
'version' => env('APP_VERSION', '7.0.6'),
/*
|--------------------------------------------------------------------------
+94
View File
@@ -586,6 +586,100 @@ return [
],
],
],
// 사이트맵 재생성 완료 — 관리자 수동 재생성(SEO 탭 "지금 생성")에 한해, 실행한 관리자에게 발송.
// 스케줄러/증분/봇 재생성은 SeoNotificationDataListener 가 context.skip 으로 제외.
// 기본 활성 채널은 database(앱 내 알림)만. mail 템플릿은 존재하되 비활성(운영자가 필요 시 활성화).
'sitemap_regenerated' => [
'hook_prefix' => 'core.seo',
'name' => ['ko' => '사이트맵 재생성 완료', 'en' => 'Sitemap Regeneration Complete'],
'description' => ['ko' => '관리자가 수동으로 실행한 사이트맵 재생성이 완료되면 실행한 관리자에게 발송되는 알림', 'en' => 'Notification sent to the admin who manually triggered sitemap regeneration when it completes'],
'channels' => ['database'],
'hooks' => ['core.seo.sitemap.after_regenerate'],
'variables' => [
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'url_count', 'description' => '생성된 총 URL 수'],
['key' => 'child_count', 'description' => '생성된 사이트맵 파일 수'],
['key' => 'action_url', 'description' => 'SEO 설정 페이지 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'is_active' => false,
'recipients' => [['type' => 'trigger_user']],
'subject' => [
'ko' => '[{app_name}] 사이트맵 재생성이 완료되었습니다',
'en' => '[{app_name}] Sitemap Regeneration Complete',
],
'body' => [
'ko' => '<h1>사이트맵 재생성 완료</h1>'
.'<p>요청하신 사이트맵 재생성이 완료되었습니다.</p>'
.'<p>총 <strong>{url_count}</strong>개의 URL 이 <strong>{child_count}</strong>개의 파일로 생성되었습니다.</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">SEO 설정 보기</a></td></tr></table>'
.'<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Sitemap Regeneration Complete</h1>'
.'<p>The sitemap regeneration you requested has completed.</p>'
.'<p>A total of <strong>{url_count}</strong> URLs were generated across <strong>{child_count}</strong> file(s).</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">View SEO Settings</a></td></tr></table>'
.'<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'trigger_user']],
'subject' => ['ko' => '사이트맵 재생성 완료', 'en' => 'Sitemap regeneration complete'],
'body' => ['ko' => '요청하신 사이트맵 재생성이 완료되었습니다. 총 {url_count}개 URL, {child_count}개 파일이 생성되었습니다.', 'en' => 'Your sitemap regeneration is complete. {url_count} URLs across {child_count} file(s) were generated.'],
'click_url' => '/admin/settings?tab=seo',
],
],
],
// 사이트맵 재생성 실패 — 관리자 수동 재생성이 실패하면 실행한 관리자에게 발송.
'sitemap_regenerate_failed' => [
'hook_prefix' => 'core.seo',
'name' => ['ko' => '사이트맵 재생성 실패', 'en' => 'Sitemap Regeneration Failed'],
'description' => ['ko' => '관리자가 수동으로 실행한 사이트맵 재생성이 최종 실패(재시도 소진)하면 실행한 관리자에게 발송되는 알림', 'en' => 'Notification sent to the admin who manually triggered sitemap regeneration when it finally fails after retries'],
'channels' => ['database'],
// 재시도 소진 시 1회만 발화하는 최종 실패 훅에 구독 (매 시도 발화하는 after_regenerate_failed 아님)
'hooks' => ['core.seo.sitemap.regenerate_failed_final'],
'variables' => [
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'error', 'description' => '실패 사유 메시지'],
['key' => 'action_url', 'description' => 'SEO 설정 페이지 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'is_active' => false,
'recipients' => [['type' => 'trigger_user']],
'subject' => [
'ko' => '[{app_name}] 사이트맵 재생성이 실패했습니다',
'en' => '[{app_name}] Sitemap Regeneration Failed',
],
'body' => [
'ko' => '<h1>사이트맵 재생성 실패</h1>'
.'<p>요청하신 사이트맵 재생성이 실패했습니다.</p>'
.'<p>오류: <strong>{error}</strong></p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">SEO 설정 보기</a></td></tr></table>'
.'<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Sitemap Regeneration Failed</h1>'
.'<p>The sitemap regeneration you requested has failed.</p>'
.'<p>Error: <strong>{error}</strong></p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">View SEO Settings</a></td></tr></table>'
.'<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'trigger_user']],
'subject' => ['ko' => '사이트맵 재생성 실패', 'en' => 'Sitemap regeneration failed'],
'body' => ['ko' => '요청하신 사이트맵 재생성이 실패했습니다. 오류: {error}', 'en' => 'Your sitemap regeneration failed. Error: {error}'],
'click_url' => '/admin/settings?tab=seo',
],
],
],
],
/*
+2
View File
@@ -202,6 +202,8 @@ sitemap.xml 재생성을 큐에 예약합니다. 관리자 수동 재생성은
예약 직후 진행상황이 `queued` 로 기록되며, 이후 진행 단계는 `GET /api/admin/seo/sitemap/status` 로 조회합니다.
이 엔드포인트로 접수된 **관리자 수동 재생성**은 실행한 관리자의 ID 를 잡에 실어, 생성 완료 시 `sitemap_regenerated`, 실패 시 `sitemap_regenerate_failed` 알림이 **그 관리자에게만** 발송됩니다(기본 채널: 앱 내 알림). 스케줄러·리소스 변경(증분)·봇 캐시 미스로 유발된 재생성은 실행 관리자가 없어 알림을 보내지 않습니다. 알림 정의는 `config/core.php` 의 `notification_definitions` 에 있으며 관리자 알림 설정에서 채널을 조정할 수 있습니다.
### GET /api/admin/seo/sitemap/status
<!-- @generated:start:api.admin.seo.sitemap.status -->
- **라우트명**: `api.admin.seo.sitemap.status`
+4
View File
@@ -689,6 +689,10 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
봇 요청 스레드에서 동기 생성하면 대용량(수백만 URL)에서 메모리 초과·타임아웃이 발생하므로 생성은 항상 큐가 담당합니다. 잡은 유니크 락(`seo-sitemap`)을 쓰므로 캐시 미스가 몰려도 동시에 여러 건이 실행되지 않습니다.
**전용 큐(`GenerateSitemapJob::QUEUE = 'sitemap'`)**: 사이트맵 생성 잡은 `sitemap` 전용 큐로 라우팅됩니다. 배포 시 이 큐 워커를 별도로 띄우되 **워커를 1개만** 배치해야 합니다(`php artisan queue:work --queue=sitemap` — supervisor 프로그램을 기본 큐와 분리해 등록). 이유: ①긴 생성 작업이 기본(`default`) 큐의 방송·알림을 막지 않아 진행상황이 실시간으로 흐르고, ②워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않습니다(큐 `retry_after` 가 잡 실행보다 짧아도 단일 워커라 재예약 중복 실행이 발생하지 않음). `sitemap` 큐 워커가 없으면 재생성 잡이 처리되지 않으니 주의합니다.
**완료/실패 알림**: 관리자 수동 재생성(위 API)은 실행한 관리자 ID 를 잡에 실어, 완료 시 `sitemap_regenerated`, **최종 실패(재시도 소진, `Job::failed()`) 시** `sitemap_regenerate_failed` 알림을 그 관리자에게 발송합니다(기본 채널: 앱 내 알림). 매 시도 실패마다 발화하는 `core.seo.sitemap.after_regenerate_failed` 훅이 아니라 최종 실패 시 1회만 발화하는 `core.seo.sitemap.regenerate_failed_final` 훅에 알림이 연결되어, 재시도 중 실패가 반복되거나 결국 성공해도 실패 알림이 중복/오발송되지 않습니다. 스케줄러·증분·봇 재생성은 실행 관리자가 없어 알림을 보내지 않습니다.
**분할 기준**: `sitemap_urls_per_file`(기본 50000) 또는 파일 크기 임계(`SitemapWriter::MAX_FILE_BYTES`, 45MB) 중 먼저 도달하는 쪽. 둘 다 sitemaps.org 프로토콜 제한(50,000 URL / 50MB)을 지키기 위한 것입니다.
**관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 `mode=full` 로 예약만 하고 즉시 진행상황(`getStatus()`)을 응답합니다. 완료 여부는 진행상황(아래) 또는 `sitemap_last_updated_at` 갱신으로 확인합니다.
@@ -12,6 +12,7 @@
- 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- SEO 설정의 사이트맵 다국어 대체 링크(hreflang) 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.sitemap_hreflang_enabled_boolean`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 관리자 사이트맵 수동 재생성 완료·실패 알림 템플릿 일본어 번역 추가 (`sitemap_regenerated`, `sitemap_regenerate_failed`) — 재생성을 실행한 관리자가 받는 앱 내 알림·메일이 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.4] - 2026-07-16
@@ -46,5 +46,37 @@
"body": "{name}様、パスワードが変更されました。ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。"
}
}
},
"sitemap_regenerated": {
"definition": {
"name": "サイトマップ再生成完了",
"description": "管理者が手動で実行したサイトマップ再生成が完了した際に、実行した管理者へ送信される通知"
},
"templates": {
"mail": {
"subject": "[{app_name}] サイトマップの再生成が完了しました",
"body": "<h1>サイトマップ再生成完了</h1><p>ご依頼いただいたサイトマップの再生成が完了しました。</p><p>合計<strong>{url_count}</strong>件のURLが<strong>{child_count}</strong>個のファイルに生成されました。</p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">SEO設定を表示</a></td></tr></table><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
},
"database": {
"subject": "サイトマップ再生成完了",
"body": "ご依頼いただいたサイトマップの再生成が完了しました。合計{url_count}件のURL、{child_count}個のファイルが生成されました。"
}
}
},
"sitemap_regenerate_failed": {
"definition": {
"name": "サイトマップ再生成失敗",
"description": "管理者が手動で実行したサイトマップ再生成が失敗した際に、実行した管理者へ送信される通知"
},
"templates": {
"mail": {
"subject": "[{app_name}] サイトマップの再生成に失敗しました",
"body": "<h1>サイトマップ再生成失敗</h1><p>ご依頼いただいたサイトマップの再生成に失敗しました。</p><p>エラー: <strong>{error}</strong></p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">SEO設定を表示</a></td></tr></table><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
},
"database": {
"subject": "サイトマップ再生成失敗",
"body": "ご依頼いただいたサイトマップの再生成に失敗しました。エラー: {error}"
}
}
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
### Changed
- 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
## [1.0.2] - 2026-07-14
+1 -1
View File
@@ -11,7 +11,7 @@
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
"g7_version": ">=7.1.0",
"g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
@@ -29,17 +29,27 @@ class PostSeoOgImageRenderingTest extends ModuleTestCase
// 의존성 모듈 (sirsoft-ecommerce / sirsoft-page) 을 active 상태로 등록 — 템플릿 의존 만족용
// (전체 install 은 비용 크므로 Module DB 행만 직접 셋업)
// 버전은 번들 매니페스트 실버전으로 등록한다. sirsoft-basic 템플릿은 의존성 제약
// (예: sirsoft-ecommerce >=1.0.2) 을 두므로, 하드코딩 버전은 확장 버전 bump 시 stale 되어
// installTemplate() 이 "버전이 맞지 않습니다" 로 실패 → 활성 user 템플릿 부재 →
// SEO 라우트 미해석 → og:image 미출력으로 이어진다. updateOrCreate 로 기존 행의
// stale 버전까지 매니페스트 값으로 교정한다(트랜잭션 롤백으로 격리).
foreach ([
['sirsoft-ecommerce', 'sirsoft', '이커머스', 'Ecommerce'],
['sirsoft-page', 'sirsoft', '페이지', 'Page'],
] as [$id, $vendor, $ko, $en]) {
\App\Models\Module::firstOrCreate(
$manifestVersion = json_decode(
(string) @file_get_contents(base_path("modules/_bundled/{$id}/module.json")),
true
)['version'] ?? '1.0.0';
\App\Models\Module::updateOrCreate(
['identifier' => $id],
[
'vendor' => $vendor,
'name' => ['ko' => $ko, 'en' => $en],
'status' => \App\Enums\ExtensionStatus::Active->value,
'version' => '1.0.0',
'version' => $manifestVersion,
'config' => [],
]
);
@@ -14,7 +14,7 @@
### Changed
- 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
## [1.0.4] - 2026-07-16
@@ -11,7 +11,7 @@
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
"en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management"
},
"g7_version": ">=7.1.0",
"g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
+1 -1
View File
@@ -14,7 +14,7 @@
### Changed
- 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
## [1.0.1] - 2026-07-08
+1 -1
View File
@@ -11,7 +11,7 @@
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
"en": "Static page (info/policy/guide) management module"
},
"g7_version": ">=7.1.0",
"g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
@@ -59,6 +59,13 @@ describe('admin_settings — sitemap 진행상황 데이터소스', () => {
expect(start!.if).toContain('queued');
expect(start!.if).toContain('running');
expect(start!.if).toContain('writing');
// onSuccess 조건은 방금 받은 응답(response.data)을 봐야 한다. 명명 소스(sitemap_status)는
// onSuccess 실행 시점엔 아직 이전 렌더의 값(stale)이라, 재생성 직후(queued)에도 폴링이
// 시작되지 않는 회귀가 발생한다(Reverb OFF 폴백 붕괴). 신선한 response 로 게이트해야 한다.
// onSuccess 의 response.data 는 응답 봉투 전체({success,message,data}) 이므로 progress 는
// response.data.data.progress (double .data) 다. 한 단계 부족하면 undefined → 폴링 미시작.
expect(start!.if).toContain('response?.data?.data?.progress?.status');
expect(start!.if).not.toContain('sitemap_status?.data?.progress?.status');
// 폴링 액션은 sitemap_status 재조회
const refetch = (start!.params.actions as AnyJson[])[0];
expect(refetch.handler).toBe('refetchDataSource');
@@ -73,6 +80,9 @@ describe('admin_settings — sitemap 진행상황 데이터소스', () => {
expect(stop).toBeTruthy();
expect(stop!.if).toContain('completed');
expect(stop!.if).toContain('failed');
// startInterval 과 동일하게 신선한 response(double .data) 로 게이트 (stale 명명 소스 금지)
expect(stop!.if).toContain('response?.data?.data?.progress?.status');
expect(stop!.if).not.toContain('sitemap_status?.data?.progress?.status');
expect(stop!.params.id).toBe('sitemap_progress_poll');
});
@@ -285,7 +285,7 @@
"onSuccess": [
{
"handler": "startInterval",
"if": "{{sitemap_status?.data?.realtime_enabled !== true && ['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '')}}",
"if": "{{response?.data?.data?.realtime_enabled !== true && ['queued','running','writing'].includes(response?.data?.data?.progress?.status ?? '')}}",
"params": {
"id": "sitemap_progress_poll",
"intervalMs": 3000,
@@ -301,7 +301,7 @@
},
{
"handler": "stopInterval",
"if": "{{['completed','failed'].includes(sitemap_status?.data?.progress?.status ?? '')}}",
"if": "{{['completed','failed'].includes(response?.data?.data?.progress?.status ?? '')}}",
"params": {
"id": "sitemap_progress_poll"
}
@@ -45,6 +45,12 @@ class JaLocaleSeoIntegrationTest extends TestCase
'ja' => '日本語',
]);
Config::set('app.translatable_locales', ['ko', 'en', 'ja']);
// hreflang alternate 방출을 기본값(true)으로 고정한다. fromConfig() 가 읽는
// g7_core_settings('seo.sitemap_hreflang_enabled') 는 부팅 시 영속 설정에서 적재되어
// settings 디스크 fake 의 영향을 받지 않으므로, 다국어 hreflang 검증이 실행 머신의
// 설정 상태에 좌우되지 않도록 명시 고정한다.
Config::set('g7_settings.core.seo.sitemap_hreflang_enabled', true);
}
public function test_sitemap_xml_includes_ja_locale_urls(): void
@@ -0,0 +1,136 @@
<?php
namespace Tests\Feature\Seo;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use App\Listeners\NotificationHookListener;
use App\Listeners\SeoNotificationDataListener;
use App\Models\User;
use App\Notifications\GenericNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
/**
* 사이트맵 재생성 알림 통합 테스트.
*
* 관리자 수동 재생성(triggered_by 존재) 완료/실패 시 실행한 관리자에게만 알림이 발송되고,
* 스케줄러/증분/봇 재생성(triggered_by null)은 발송되지 않는지 end-to-end 로 검증합니다.
*/
class SitemapRegenerateNotificationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// 알림 정의(sitemap_regenerated / _failed) + 동적 훅 구독 + extract_data 필터 등록
$this->artisan('db:seed', ['--class' => 'NotificationDefinitionSeeder']);
app(NotificationHookListener::class)->registerDynamicHooks();
HookListenerRegistrar::register(SeoNotificationDataListener::class, 'core');
}
private function completionResult(): array
{
return [
'success' => true,
'status' => 'updated',
'data' => ['url_count' => 720, 'child_count' => 1],
];
}
public function test_manual_completion_notifies_only_triggering_admin(): void
{
$admin = User::factory()->create();
$other = User::factory()->create();
Notification::fake();
HookManager::doAction('core.seo.sitemap.after_regenerate', $this->completionResult(), $admin->id);
Notification::assertSentTo($admin, GenericNotification::class);
Notification::assertNotSentTo($other, GenericNotification::class);
}
public function test_non_manual_completion_notifies_nobody(): void
{
User::factory()->create();
Notification::fake();
// triggered_by null = 스케줄러/증분/봇 → skip
HookManager::doAction('core.seo.sitemap.after_regenerate', $this->completionResult(), null);
Notification::assertNothingSent();
}
public function test_manual_final_failure_notifies_triggering_admin(): void
{
$admin = User::factory()->create();
Notification::fake();
// 최종 실패(재시도 소진) 훅 = Job::failed() 가 발화
HookManager::doAction('core.seo.sitemap.regenerate_failed_final', [
'success' => false,
'status' => 'failed',
'message' => '디스크 용량 부족',
], $admin->id);
Notification::assertSentTo($admin, GenericNotification::class);
}
public function test_per_attempt_failure_hook_does_not_notify(): void
{
$admin = User::factory()->create();
Notification::fake();
// 매 시도마다 발화하는 per-attempt 훅은 알림을 유발하지 않는다(최종 실패 훅만 구독).
// → 재시도 중 실패가 반복돼도, 결국 성공해도 실패 알림이 중복/오발송되지 않음.
HookManager::doAction('core.seo.sitemap.after_regenerate_failed', [
'success' => false,
'status' => 'failed',
'message' => '일시적 오류',
], $admin->id);
Notification::assertNothingSent();
}
public function test_job_failed_notifies_triggering_admin_via_final_hook(): void
{
$admin = User::factory()->create();
Notification::fake();
// Job::failed() (재시도 소진) 가 최종 실패 훅을 발화 → 유발 관리자에게 알림
(new \App\Jobs\GenerateSitemapJob(\App\Enums\SitemapGenerationMode::Full, $admin->id))
->failed(new \RuntimeException('boom'));
Notification::assertSentTo($admin, GenericNotification::class);
}
public function test_job_failed_without_trigger_user_notifies_nobody(): void
{
User::factory()->create();
Notification::fake();
// 스케줄러/증분 잡(triggeredBy null)의 최종 실패는 알림 없음
(new \App\Jobs\GenerateSitemapJob(\App\Enums\SitemapGenerationMode::Auto, null))
->failed(new \RuntimeException('boom'));
Notification::assertNothingSent();
}
public function test_default_active_channel_is_database_only(): void
{
$definition = \App\Models\NotificationDefinition::where('type', 'sitemap_regenerated')->first();
$this->assertNotNull($definition);
$this->assertSame(['database'], $definition->channels);
// mail 템플릿은 존재하되 비활성, database 템플릿은 활성
$mail = $definition->templates()->where('channel', 'mail')->first();
$database = $definition->templates()->where('channel', 'database')->first();
$this->assertNotNull($mail, 'mail 템플릿 행은 존재해야 한다(운영자가 필요 시 활성화)');
$this->assertFalse((bool) $mail->is_active, 'mail 템플릿은 기본 비활성');
$this->assertTrue((bool) $database->is_active, 'database 템플릿은 기본 활성');
}
}
@@ -254,3 +254,44 @@ test('#481 - SEO 탭 진입 시 sitemap 상태 API 가 진행상황/실시간
expect(body?.data).toHaveProperty('progress');
expect(body?.data).toHaveProperty('last_updated_at');
});
// @scenario tab=seo, permitted=yes
// @effects regenerate_is_async
test('#481 - 재생성 후 실시간/폴링 모드에 맞게 진행상황이 갱신된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
// 상태(GET) 응답을 가로채 실시간 여부 판정 + 재조회 횟수 카운트 (앱의 인증 요청 기준)
let realtimeEnabled: boolean | null = null;
let statusGets = 0;
page.on('response', async (r) => {
if (r.url().includes('/api/admin/seo/sitemap/status') && r.request().method() === 'GET') {
statusGets += 1;
if (realtimeEnabled === null) {
try { realtimeEnabled = (await r.json())?.data?.realtime_enabled === true; } catch { /* ignore */ }
}
}
});
await gotoSeoTab(page);
await expect.poll(() => realtimeEnabled, { timeout: 20_000 }).not.toBeNull();
// 여기부터 재생성 이후의 폴링만 센다
statusGets = 0;
await page.locator('#sitemap_last_updated_block button').first().click();
await page.waitForResponse((r) => r.url().includes('/api/admin/seo/sitemap/regenerate'), { timeout: 20_000 });
const pollingMode = realtimeEnabled === false;
// 8초 관찰
await page.waitForTimeout(8_000);
if (pollingMode) {
// Reverb OFF: startInterval 폴링이 상태를 반복 재조회해야 한다.
// 회귀(폴링 미시작) 시 재생성 직후 refetch 1회로 멈춰 배지가 갱신되지 않는다.
expect(statusGets).toBeGreaterThanOrEqual(2);
} else {
// 실시간(WebSocket) 모드면 폴링하지 않는다 — 재생성 직후 refetch 수준
expect(statusGets).toBeLessThanOrEqual(2);
}
});
@@ -47,6 +47,20 @@ class GenerateSitemapJobTest extends TestCase
$this->assertSame(120, $job->backoff);
}
/**
* 전용 'sitemap' 큐로 라우팅되는지 확인합니다.
*
* default 큐와 분리해, 긴 생성 작업이 방송/알림을 막지 않고(실시간 진행상황),
* 이 큐 워커 1개 제약으로 동시 중복 생성을 차단합니다.
*/
public function test_job_routes_to_dedicated_sitemap_queue(): void
{
$job = new GenerateSitemapJob;
$this->assertSame('sitemap', GenerateSitemapJob::QUEUE);
$this->assertSame('sitemap', $job->queue);
}
/**
* ShouldBeUnique 를 구현하여 동시 실행을 막는지 확인합니다.
*
@@ -0,0 +1,84 @@
<?php
namespace Tests\Unit\Listeners;
use App\Listeners\SeoNotificationDataListener;
use Tests\TestCase;
/**
* SeoNotificationDataListener 단위 테스트.
*
* 사이트맵 재생성 알림의 핵심 정책 — 관리자 수동 실행(triggered_by 존재)만 발송하고,
* 스케줄러/증분/봇(triggered_by null)은 context.skip 으로 제외 — 을 검증합니다.
*/
class SeoNotificationDataListenerTest extends TestCase
{
private SeoNotificationDataListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->listener = new SeoNotificationDataListener;
}
private function default(): array
{
return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []];
}
public function test_manual_completion_extracts_data_and_trigger_user(): void
{
$result = ['status' => 'updated', 'success' => true, 'data' => ['url_count' => 720, 'child_count' => 3]];
$out = $this->listener->extractData($this->default(), 'sitemap_regenerated', [$result, 42]);
$this->assertSame(42, $out['context']['trigger_user_id']);
$this->assertArrayNotHasKey('skip', $out['context']);
$this->assertSame('720', $out['data']['url_count']);
$this->assertSame('3', $out['data']['child_count']);
$this->assertArrayHasKey('action_url', $out['data']);
}
public function test_non_manual_completion_is_skipped(): void
{
$result = ['status' => 'updated', 'success' => true, 'data' => ['url_count' => 720]];
// triggered_by 없음(스케줄러/증분/봇) → skip
$out = $this->listener->extractData($this->default(), 'sitemap_regenerated', [$result, null]);
$this->assertTrue($out['context']['skip']);
$this->assertSame([], $out['data']);
}
public function test_manual_failure_extracts_error_and_trigger_user(): void
{
$result = ['status' => 'failed', 'success' => false, 'message' => 'disk full'];
$out = $this->listener->extractData($this->default(), 'sitemap_regenerate_failed', [$result, 7]);
$this->assertSame(7, $out['context']['trigger_user_id']);
$this->assertSame('disk full', $out['data']['error']);
}
public function test_non_manual_failure_is_skipped(): void
{
$out = $this->listener->extractData($this->default(), 'sitemap_regenerate_failed', [['message' => 'x'], null]);
$this->assertTrue($out['context']['skip']);
}
public function test_unknown_type_returns_default_untouched(): void
{
$out = $this->listener->extractData($this->default(), 'some_other_type', [[], 1]);
$this->assertSame($this->default(), $out);
}
public function test_subscribes_to_seo_extract_data_filter(): void
{
$hooks = SeoNotificationDataListener::getSubscribedHooks();
$this->assertArrayHasKey('core.seo.notification.extract_data', $hooks);
$this->assertSame('filter', $hooks['core.seo.notification.extract_data']['type']);
}
}
+6
View File
@@ -42,6 +42,12 @@ class SitemapGeneratorTest extends TestCase
Config::set('app.locale', 'ko');
Config::set('app.supported_locales', ['ko']);
// hreflang alternate 방출을 기본값(true)으로 고정한다. SitemapXmlRenderer::fromConfig()
// 는 g7_core_settings('seo.sitemap_hreflang_enabled') 를 읽는데, 이 값은 부팅 시
// storage/app/settings/seo.json 으로 적재되며 settings 디스크 fake 의 영향을 받지 않는다.
// 실행 머신의 영속 설정에 다국어 hreflang 검증 결과가 좌우되지 않도록 명시 고정한다.
Config::set('g7_settings.core.seo.sitemap_hreflang_enabled', true);
$this->routeResolver = Mockery::mock(TemplateRouteResolver::class);
$this->templateService = Mockery::mock(TemplateService::class);
+25 -2
View File
@@ -121,10 +121,33 @@ class SitemapProgressTest extends TestCase
$this->progress->start('full');
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
// payload 는 상태 API 봉투와 동형: 최상위 data 안에 progress/realtime_enabled
// (target_source 가 대상 소스를 통째 교체해도 `sitemap_status.data.*` 바인딩 유지)
return $event->channel === 'core.admin.seo.sitemap'
&& $event->eventName === 'sitemap.progress.updated'
&& ($event->payload['realtime_enabled'] ?? null) === true
&& ($event->payload['progress']['status'] ?? null) === 'queued';
&& ($event->payload['data']['realtime_enabled'] ?? null) === true
&& ($event->payload['data']['progress']['status'] ?? null) === 'queued'
&& array_key_exists('last_updated_at', $event->payload['data']);
});
}
/**
* 완료 방송은 방금 커밋된 생성 시각을 data.last_updated_at 으로 실어 "마지막 생성"을 실시간 갱신한다.
*/
public function test_complete_broadcast_carries_new_last_updated_at(): void
{
$this->enableReverb();
Event::fake([GenericBroadcastEvent::class]);
$this->progress->complete([
'url_count' => 720,
'child_count' => 1,
'generated_at' => '2026-07-19T00:00:00+00:00',
]);
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
return ($event->payload['data']['last_updated_at'] ?? null) === '2026-07-19T00:00:00+00:00'
&& ($event->payload['data']['progress']['status'] ?? null) === 'completed';
});
}
@@ -2,15 +2,15 @@
namespace Tests\Unit\Upgrades;
require_once __DIR__.'/../../../upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php';
require_once __DIR__.'/../../../upgrades/data/7.0.6/migrations/01_NullifyUntouchedSeoCacheOverrides.php';
use App\Extension\UpgradeContext;
use App\Upgrades\Data\V7_1_0\Migrations\NullifyUntouchedSeoCacheOverrides;
use App\Upgrades\Data\V7_0_6\Migrations\NullifyUntouchedSeoCacheOverrides;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* 7.1.0 업그레이드 스텝 — SEO 캐시 오버라이드 이행 (결정 D19)
* 7.0.6 업그레이드 스텝 — SEO 캐시 오버라이드 이행 (결정 D19)
*
* 판정 규칙:
* 옛 기본값과 같으면 "안 건드린 것" → null(미설정)로 비움
@@ -40,7 +40,7 @@ class NullifyUntouchedSeoCacheOverridesTest extends TestCase
*/
private function context(): UpgradeContext
{
return new UpgradeContext('7.0.5', '7.1.0', '7.1.0');
return new UpgradeContext('7.0.5', '7.0.6', '7.0.6');
}
/**
@@ -5,9 +5,9 @@ namespace App\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* 코어 7.1.0 업그레이드 스텝
* 코어 7.0.6 업그레이드 스텝
*
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.1.0/` 안에 격리된다:
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.0.6/` 안에 격리된다:
*
* - migrations/
* 01_NullifyUntouchedSeoCacheOverrides.php
@@ -16,15 +16,15 @@ use App\Extension\AbstractUpgradeStep;
*
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음.
*
* @upgrade-path 7.0.x → 7.1.0
* @upgrade-path 7.0.x → 7.0.6
*
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.1.0/migrations/` 의 버전 namespace
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.0.6/migrations/` 의 버전 namespace
* 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작
* 100% 동일 보장" invariant 가 성립.
*
* 상세: docs/extension/upgrade-step-guide.md §13 "버전별 데이터 스냅샷"
*/
class Upgrade_7_1_0 extends AbstractUpgradeStep
class Upgrade_7_0_6 extends AbstractUpgradeStep
{
// 모든 로직 위임 — data/7.1.0/ 가 SSoT.
// 모든 로직 위임 — data/7.0.6/ 가 SSoT.
}
@@ -1,6 +1,6 @@
<?php
namespace App\Upgrades\Data\V7_1_0\Migrations;
namespace App\Upgrades\Data\V7_0_6\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
@@ -16,7 +16,7 @@ use Illuminate\Support\Facades\Storage;
* `g7_core_settings('cache.X', g7_core_settings('seo.Y', 기본값))` 형태로 cache 를
* **항상** 우선시켜 SEO 탭 입력이 조용히 무시되었다(운영자가 저장해도 무효).
*
* 7.1.0 의 새 의미:
* 7.0.6 의 새 의미:
* 고급 탭 = 메인, SEO 탭에 **별도 지정이 있으면** 그것이 오버라이드.
* "별도 지정" 판정은 null 여부로 하므로 미설정 상태를 표현할 수 있어야 한다.
*
@@ -86,10 +86,10 @@ final class NullifyUntouchedSeoCacheOverrides implements DataMigration
} catch (\Throwable $e) {
// 업그레이드 중단 금지 — 설정 정리 실패가 업그레이드 전체를 막지 않는다.
$context->logger->warning(sprintf(
'[7.1.0] NullifyUntouchedSeoCacheOverrides 실패 (seo.json 보존, 계속 진행): %s',
'[7.0.6] NullifyUntouchedSeoCacheOverrides 실패 (seo.json 보존, 계속 진행): %s',
$e->getMessage(),
));
Log::warning('7.1.0 NullifyUntouchedSeoCacheOverrides 실패', [
Log::warning('7.0.6 NullifyUntouchedSeoCacheOverrides 실패', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
@@ -129,20 +129,20 @@ final class NullifyUntouchedSeoCacheOverrides implements DataMigration
foreach ($kept as $key => $value) {
$context->logger->info(sprintf(
'[7.1.0] seo.%s = %s 는 옛 기본값과 달라 오버라이드로 보존 — 이제부터 실제 적용됩니다',
'[7.0.6] seo.%s = %s 는 옛 기본값과 달라 오버라이드로 보존 — 이제부터 실제 적용됩니다',
$key,
var_export($value, true),
));
}
if ($cleared === []) {
$context->logger->info('[7.1.0] SEO 캐시 설정 — 비울 대상 없음 (이미 이행 완료이거나 전부 오버라이드)');
$context->logger->info('[7.0.6] SEO 캐시 설정 — 비울 대상 없음 (이미 이행 완료이거나 전부 오버라이드)');
return;
}
$this->writeSeoSettings($settings);
$context->logger->info('[7.1.0] SEO 캐시 설정 — 미설정(null)으로 비운 키: '.implode(', ', $cleared));
$context->logger->info('[7.0.6] SEO 캐시 설정 — 미설정(null)으로 비운 키: '.implode(', ', $cleared));
}
/**
@@ -154,7 +154,7 @@ final class NullifyUntouchedSeoCacheOverrides implements DataMigration
private function readSeoSettings(UpgradeContext $context): ?array
{
if (! Storage::disk('settings')->exists(self::SEO_SETTINGS_FILE)) {
$context->logger->info('[7.1.0] seo.json 부재 — 정리 대상 없음, skip');
$context->logger->info('[7.0.6] seo.json 부재 — 정리 대상 없음, skip');
return null;
}
@@ -163,7 +163,7 @@ final class NullifyUntouchedSeoCacheOverrides implements DataMigration
if (! is_array($decoded)) {
// 손상 파일을 덮어쓰지 않는다 (파괴 금지)
$context->logger->warning('[7.1.0] seo.json 파싱 실패 — 파일 보존하고 skip');
$context->logger->warning('[7.0.6] seo.json 파싱 실패 — 파일 보존하고 skip');
return null;
}