From 124433148c49cf96d31d4a55d2cf35fa89ebdf42 Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Sun, 19 Jul 2026 18:57:51 +0900
Subject: [PATCH] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?=
=?UTF-8?q?=EB=A7=B5=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=EC=95=8C=EB=A6=BC?=
=?UTF-8?q?=C2=B7=ED=8F=B4=EB=A7=81=20=ED=8F=B4=EB=B0=B1=20+=20=EC=BD=94?=
=?UTF-8?q?=EC=96=B4=207.0.6=20=ED=99=95=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
이슈 후속(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.
---
.env.example | 2 +-
.env.testing.example | 2 +-
CHANGELOG.md | 3 +-
INSTALL.md | 2 +-
README.md | 2 +-
.../Api/Admin/SeoCacheController.php | 4 +-
app/Jobs/GenerateSitemapJob.php | 30 +++-
app/Listeners/SeoNotificationDataListener.php | 133 +++++++++++++++++
app/Seo/SitemapManager.php | 8 +-
app/Seo/SitemapProgress.php | 38 +++--
config/app.php | 2 +-
config/core.php | 94 ++++++++++++
docs/backend/api/seo.md | 2 +
docs/backend/seo-system.md | 4 +
lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 1 +
.../g7-core-ja/seed/notifications.json | 32 +++++
modules/_bundled/sirsoft-board/CHANGELOG.md | 2 +-
modules/_bundled/sirsoft-board/module.json | 2 +-
.../Seo/PostSeoOgImageRenderingTest.php | 14 +-
.../_bundled/sirsoft-ecommerce/CHANGELOG.md | 2 +-
.../_bundled/sirsoft-ecommerce/module.json | 2 +-
modules/_bundled/sirsoft-page/CHANGELOG.md | 2 +-
modules/_bundled/sirsoft-page/module.json | 2 +-
.../admin-settings-sitemap-progress.test.tsx | 10 ++
.../layouts/admin_settings.json | 4 +-
.../Seo/JaLocaleSeoIntegrationTest.php | 6 +
.../Seo/SitemapRegenerateNotificationTest.php | 136 ++++++++++++++++++
...settings-sitemap-generation-fields.spec.ts | 41 ++++++
tests/Unit/Jobs/GenerateSitemapJobTest.php | 14 ++
.../SeoNotificationDataListenerTest.php | 84 +++++++++++
tests/Unit/Seo/SitemapGeneratorTest.php | 6 +
tests/Unit/Seo/SitemapProgressTest.php | 27 +++-
.../NullifyUntouchedSeoCacheOverridesTest.php | 8 +-
.../{Upgrade_7_1_0.php => Upgrade_7_0_6.php} | 12 +-
.../01_NullifyUntouchedSeoCacheOverrides.php | 18 +--
35 files changed, 698 insertions(+), 53 deletions(-)
create mode 100644 app/Listeners/SeoNotificationDataListener.php
create mode 100644 tests/Feature/Seo/SitemapRegenerateNotificationTest.php
create mode 100644 tests/Unit/Listeners/SeoNotificationDataListenerTest.php
rename upgrades/{Upgrade_7_1_0.php => Upgrade_7_0_6.php} (71%)
rename upgrades/data/{7.1.0 => 7.0.6}/migrations/01_NullifyUntouchedSeoCacheOverrides.php (92%)
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 @@
-
+
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}개의 파일로 생성되었습니다.
'
+ .''
+ .'감사합니다,
{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).
'
+ .''
+ .'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}
'
+ .''
+ .'감사합니다,
{app_name}
',
+ 'en' => 'Sitemap Regeneration Failed
'
+ .'The sitemap regeneration you requested has failed.
'
+ .'Error: {error}
'
+ .''
+ .'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}個のファイルに生成されました。
ありがとうございます。
{app_name}
"
+ },
+ "database": {
+ "subject": "サイトマップ再生成完了",
+ "body": "ご依頼いただいたサイトマップの再生成が完了しました。合計{url_count}件のURL、{child_count}個のファイルが生成されました。"
+ }
+ }
+ },
+ "sitemap_regenerate_failed": {
+ "definition": {
+ "name": "サイトマップ再生成失敗",
+ "description": "管理者が手動で実行したサイトマップ再生成が失敗した際に、実行した管理者へ送信される通知"
+ },
+ "templates": {
+ "mail": {
+ "subject": "[{app_name}] サイトマップの再生成に失敗しました",
+ "body": "サイトマップ再生成失敗
ご依頼いただいたサイトマップの再生成に失敗しました。
エラー: {error}
ありがとうございます。
{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;
}