diff --git a/.env.example b/.env.example index 03a6f2e9..861b052d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.testing.example b/.env.testing.example index 622261bc..ecfb2ee2 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 755e89b7..da67a6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/INSTALL.md b/INSTALL.md index a43b314f..0f49bfba 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 diff --git a/README.md b/README.md index 883a5789..63ae0606 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php index 0345c9a0..8f3e7be8 100644 --- a/app/Http/Controllers/Api/Admin/SeoCacheController.php +++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php @@ -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); diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php index 64b1d938..be491038 100644 --- a/app/Jobs/GenerateSitemapJob.php +++ b/app/Jobs/GenerateSitemapJob.php @@ -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(), ]); diff --git a/app/Listeners/SeoNotificationDataListener.php b/app/Listeners/SeoNotificationDataListener.php new file mode 100644 index 00000000..ea39ad4b --- /dev/null +++ b/app/Listeners/SeoNotificationDataListener.php @@ -0,0 +1,133 @@ + [ + '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]]; + } +} diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index 3e19f033..3b85230b 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -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} * 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; } diff --git a/app/Seo/SitemapProgress.php b/app/Seo/SitemapProgress.php index bec3f7a2..e6233e1e 100644 --- a/app/Seo/SitemapProgress.php +++ b/app/Seo/SitemapProgress.php @@ -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 $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, + ], + ]); } } diff --git a/config/app.php b/config/app.php index 468cfaad..fd969be6 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.1.0'), + 'version' => env('APP_VERSION', '7.0.6'), /* |-------------------------------------------------------------------------- diff --git a/config/core.php b/config/core.php index 50534ca2..561bff61 100644 --- a/config/core.php +++ b/config/core.php @@ -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' => '

사이트맵 재생성 완료

' + .'

요청하신 사이트맵 재생성이 완료되었습니다.

' + .'

총 {url_count}개의 URL 이 {child_count}개의 파일로 생성되었습니다.

' + .'
SEO 설정 보기
' + .'

감사합니다,
{app_name}

', + 'en' => '

Sitemap Regeneration Complete

' + .'

The sitemap regeneration you requested has completed.

' + .'

A total of {url_count} URLs were generated across {child_count} file(s).

' + .'
View SEO Settings
' + .'

Thank you,
{app_name}

', + ], + ], + [ + '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' => '

사이트맵 재생성 실패

' + .'

요청하신 사이트맵 재생성이 실패했습니다.

' + .'

오류: {error}

' + .'
SEO 설정 보기
' + .'

감사합니다,
{app_name}

', + 'en' => '

Sitemap Regeneration Failed

' + .'

The sitemap regeneration you requested has failed.

' + .'

Error: {error}

' + .'
View SEO Settings
' + .'

Thank you,
{app_name}

', + ], + ], + [ + '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', + ], + ], + ], ], /* diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md index 5af7356e..70e1eadb 100644 --- a/docs/backend/api/seo.md +++ b/docs/backend/api/seo.md @@ -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 - **라우트명**: `api.admin.seo.sitemap.status` diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index 86bfd9ab..fb28f1e4 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -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` 갱신으로 확인합니다. diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 19ce0b01..0c15426f 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -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 diff --git a/lang-packs/_bundled/g7-core-ja/seed/notifications.json b/lang-packs/_bundled/g7-core-ja/seed/notifications.json index b7e1699b..f9ed683c 100644 --- a/lang-packs/_bundled/g7-core-ja/seed/notifications.json +++ b/lang-packs/_bundled/g7-core-ja/seed/notifications.json @@ -46,5 +46,37 @@ "body": "{name}様、パスワードが変更されました。ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。" } } + }, + "sitemap_regenerated": { + "definition": { + "name": "サイトマップ再生成完了", + "description": "管理者が手動で実行したサイトマップ再生成が完了した際に、実行した管理者へ送信される通知" + }, + "templates": { + "mail": { + "subject": "[{app_name}] サイトマップの再生成が完了しました", + "body": "

サイトマップ再生成完了

ご依頼いただいたサイトマップの再生成が完了しました。

合計{url_count}件のURLが{child_count}個のファイルに生成されました。

SEO設定を表示

ありがとうございます。
{app_name}

" + }, + "database": { + "subject": "サイトマップ再生成完了", + "body": "ご依頼いただいたサイトマップの再生成が完了しました。合計{url_count}件のURL、{child_count}個のファイルが生成されました。" + } + } + }, + "sitemap_regenerate_failed": { + "definition": { + "name": "サイトマップ再生成失敗", + "description": "管理者が手動で実行したサイトマップ再生成が失敗した際に、実行した管理者へ送信される通知" + }, + "templates": { + "mail": { + "subject": "[{app_name}] サイトマップの再生成に失敗しました", + "body": "

サイトマップ再生成失敗

ご依頼いただいたサイトマップの再生成に失敗しました。

エラー: {error}

SEO設定を表示

ありがとうございます。
{app_name}

" + }, + "database": { + "subject": "サイトマップ再生成失敗", + "body": "ご依頼いただいたサイトマップの再生成に失敗しました。エラー: {error}" + } + } } } diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 43cb5bb9..4270bdb7 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -14,7 +14,7 @@ ### Changed - 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. -- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ## [1.0.2] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index e21da48b..14ab4275 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -11,7 +11,7 @@ "ko": "게시판 관리를 위한 모듈", "en": "Module for board management" }, - "g7_version": ">=7.1.0", + "g7_version": ">=7.0.6", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php index f2412274..5ae3138b 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php @@ -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' => [], ] ); diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 84353f0e..d0d8f871 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -14,7 +14,7 @@ ### Changed - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. -- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 4afa939d..0ac4ee0a 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -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": {} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index b624481d..162271ab 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -14,7 +14,7 @@ ### Changed - 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. -- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ## [1.0.1] - 2026-07-08 diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json index cf7b196e..f3e4bc5d 100644 --- a/modules/_bundled/sirsoft-page/module.json +++ b/modules/_bundled/sirsoft-page/module.json @@ -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": {} diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx index a946e165..186df16b 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx @@ -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'); }); diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json index c7564690..98041494 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json @@ -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" } diff --git a/tests/Feature/Seo/JaLocaleSeoIntegrationTest.php b/tests/Feature/Seo/JaLocaleSeoIntegrationTest.php index 6192db34..60e94c57 100644 --- a/tests/Feature/Seo/JaLocaleSeoIntegrationTest.php +++ b/tests/Feature/Seo/JaLocaleSeoIntegrationTest.php @@ -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 diff --git a/tests/Feature/Seo/SitemapRegenerateNotificationTest.php b/tests/Feature/Seo/SitemapRegenerateNotificationTest.php new file mode 100644 index 00000000..5a7839ba --- /dev/null +++ b/tests/Feature/Seo/SitemapRegenerateNotificationTest.php @@ -0,0 +1,136 @@ +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 템플릿은 기본 활성'); + } +} diff --git a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts index f61a297a..ab5f0da3 100644 --- a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -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); + } +}); diff --git a/tests/Unit/Jobs/GenerateSitemapJobTest.php b/tests/Unit/Jobs/GenerateSitemapJobTest.php index 9a39804d..49350d39 100644 --- a/tests/Unit/Jobs/GenerateSitemapJobTest.php +++ b/tests/Unit/Jobs/GenerateSitemapJobTest.php @@ -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 를 구현하여 동시 실행을 막는지 확인합니다. * diff --git a/tests/Unit/Listeners/SeoNotificationDataListenerTest.php b/tests/Unit/Listeners/SeoNotificationDataListenerTest.php new file mode 100644 index 00000000..26e5a8a6 --- /dev/null +++ b/tests/Unit/Listeners/SeoNotificationDataListenerTest.php @@ -0,0 +1,84 @@ +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']); + } +} diff --git a/tests/Unit/Seo/SitemapGeneratorTest.php b/tests/Unit/Seo/SitemapGeneratorTest.php index a4dd538e..b95c6c4c 100644 --- a/tests/Unit/Seo/SitemapGeneratorTest.php +++ b/tests/Unit/Seo/SitemapGeneratorTest.php @@ -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); diff --git a/tests/Unit/Seo/SitemapProgressTest.php b/tests/Unit/Seo/SitemapProgressTest.php index cdfa73dd..4f510921 100644 --- a/tests/Unit/Seo/SitemapProgressTest.php +++ b/tests/Unit/Seo/SitemapProgressTest.php @@ -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'; }); } diff --git a/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php b/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php index bd7afa3e..c9afaa59 100644 --- a/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php +++ b/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php @@ -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'); } /** diff --git a/upgrades/Upgrade_7_1_0.php b/upgrades/Upgrade_7_0_6.php similarity index 71% rename from upgrades/Upgrade_7_1_0.php rename to upgrades/Upgrade_7_0_6.php index 714b6c91..e5e9076f 100644 --- a/upgrades/Upgrade_7_1_0.php +++ b/upgrades/Upgrade_7_0_6.php @@ -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. } diff --git a/upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php b/upgrades/data/7.0.6/migrations/01_NullifyUntouchedSeoCacheOverrides.php similarity index 92% rename from upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php rename to upgrades/data/7.0.6/migrations/01_NullifyUntouchedSeoCacheOverrides.php index e2396118..a3517261 100644 --- a/upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php +++ b/upgrades/data/7.0.6/migrations/01_NullifyUntouchedSeoCacheOverrides.php @@ -1,6 +1,6 @@ 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; }