diff --git a/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php index 44c619f7..3e97a5b9 100644 --- a/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php +++ b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php @@ -56,6 +56,7 @@ interface SitemapUrlRepositoryInterface * * @param string $contributor 기여자 식별자 * @param iterable $entries URL 항목 스트림 + * @return int 삽입된 행 수 (진행상황 누적 URL 표기용 — count 쿼리 없이 스트림 누적) */ - public function replaceAllForContributor(string $contributor, iterable $entries): void; + public function replaceAllForContributor(string $contributor, iterable $entries): int; } diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php index f0942fdd..0345c9a0 100644 --- a/app/Http/Controllers/Api/Admin/SeoCacheController.php +++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php @@ -2,12 +2,14 @@ namespace App\Http\Controllers\Api\Admin; +use App\Enums\SitemapGenerationMode; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Admin\SeoCacheClearRequest; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheStatsService; use App\Seo\SitemapManager; +use App\Seo\SitemapProgress; use Carbon\Carbon; use Illuminate\Http\JsonResponse; @@ -21,7 +23,8 @@ class SeoCacheController extends AdminBaseController public function __construct( private SeoCacheStatsService $statsService, private SeoCacheManagerInterface $cacheManager, - private SitemapManager $sitemapManager + private SitemapManager $sitemapManager, + private SitemapProgress $sitemapProgress ) { parent::__construct(); } @@ -111,12 +114,25 @@ class SeoCacheController extends AdminBaseController return $this->error('seo.sitemap_disabled', 400); } - GenerateSitemapJob::dispatch(); + // 관리자 수동 재생성은 항상 전체(Full) — 현 생성 상태와 무관하게 전량 재생성 (D7) + GenerateSitemapJob::dispatch(SitemapGenerationMode::Full); - return $this->success('seo.sitemap_regenerate_dispatched', [ - 'status' => 'dispatched', - ...$this->sitemapManager->getStatus(), - ]); + // 큐 대기 중에도 UI 가 'queued' 를 표시하도록 즉시 기록 + $this->sitemapProgress->start(SitemapGenerationMode::Full->value); + + return $this->success('seo.sitemap_regenerate_dispatched', $this->sitemapManager->getStatus()); + } + + /** + * Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다. + * + * SEO 탭 진입 시 초기 로드용이며, 폴링(Reverb OFF)일 때 주기적으로 재조회됩니다. + * + * @return JsonResponse 진행상황(progress) + last_updated_at + realtime_enabled 를 포함한 JSON 응답 + */ + public function sitemapStatus(): JsonResponse + { + return $this->success('messages.success', $this->sitemapManager->getStatus()); } /** diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php index a061c8d8..64b1d938 100644 --- a/app/Jobs/GenerateSitemapJob.php +++ b/app/Jobs/GenerateSitemapJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Enums\SitemapGenerationMode; use App\Seo\SitemapManager; +use App\Seo\SitemapProgress; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; @@ -108,6 +109,9 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue */ public function failed(\Throwable $exception): void { + // 잡 최종 실패(재시도 소진/타임아웃) 시 진행상황을 'failed' 로 고정 — 무한 running 방지 + app(SitemapProgress::class)->fail($exception->getMessage()); + Log::error('[SEO] Sitemap generation failed', [ 'error' => $exception->getMessage(), ]); diff --git a/app/Repositories/SitemapUrlRepository.php b/app/Repositories/SitemapUrlRepository.php index 9e9541c9..e2e1025d 100644 --- a/app/Repositories/SitemapUrlRepository.php +++ b/app/Repositories/SitemapUrlRepository.php @@ -92,7 +92,7 @@ class SitemapUrlRepository implements SitemapUrlRepositoryInterface /** * {@inheritDoc} */ - public function replaceAllForContributor(string $contributor, iterable $entries): void + public function replaceAllForContributor(string $contributor, iterable $entries): int { // 기여자 스코프 전량 제거 후 청크 삽입. 대용량(수백만) 삽입을 단일 트랜잭션으로 // 감싸면 락/메모리 부담이 크므로, 삭제 후 청크별 삽입으로 유계 메모리를 유지합니다. @@ -100,6 +100,7 @@ class SitemapUrlRepository implements SitemapUrlRepositoryInterface $now = now(); $batch = []; + $inserted = 0; foreach ($entries as $entry) { $batch[] = $this->toRow( (string) ($entry['resource_type'] ?? 'unknown'), @@ -113,13 +114,17 @@ class SitemapUrlRepository implements SitemapUrlRepositoryInterface if (count($batch) >= self::INSERT_CHUNK) { SitemapUrl::insert($batch); + $inserted += count($batch); $batch = []; } } if ($batch !== []) { SitemapUrl::insert($batch); + $inserted += count($batch); } + + return $inserted; } /** diff --git a/app/Seo/SeoServiceProvider.php b/app/Seo/SeoServiceProvider.php index f2b73212..7db85ffa 100644 --- a/app/Seo/SeoServiceProvider.php +++ b/app/Seo/SeoServiceProvider.php @@ -35,6 +35,7 @@ class SeoServiceProvider extends ServiceProvider $this->app->singleton(SeoConfigMerger::class); $this->app->singleton(SitemapFileStore::class); $this->app->singleton(SitemapIndexer::class); + $this->app->singleton(SitemapProgress::class); // Sitemap 파일 입출력은 비공개 디스크(local = storage/app/private, serve=false)를 사용합니다. // public/storage 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다. diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index 25b0eb7f..3e19f033 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -36,6 +36,7 @@ class SitemapManager private ConfigRepositoryInterface $configRepository, private StorageInterface $storage, private SitemapUrlRepositoryInterface $repository, + private SitemapProgress $progress, ) {} /** @@ -70,6 +71,8 @@ class SitemapManager $this->rebuildStore(); } + $this->progress->writing($this->repository->countVisible()); + $meta = $this->generator->generateToWriterFromEntries( $this->makeWriter(), $this->streamStoreEntries(), @@ -98,6 +101,8 @@ class SitemapManager ], ]; + $this->progress->complete($meta); + HookManager::doAction('core.seo.sitemap.after_regenerate', $result); return $result; @@ -106,6 +111,8 @@ class SitemapManager 'error' => $e->getMessage(), ]); + $this->progress->fail($e->getMessage()); + $result = [ 'success' => false, 'status' => 'failed', @@ -119,9 +126,13 @@ class SitemapManager } /** - * 현재 sitemap 의 메타데이터를 반환합니다. + * 현재 sitemap 의 메타데이터와 진행상황을 반환합니다. * - * @return array{last_updated_at: ?string} + * realtime_enabled 는 설정 SSoT(drivers.websocket_enabled)와 실제 적용 config + * (broadcasting.default) 를 함께 확인해 판정합니다 — 프론트가 실시간(구독)/폴링을 + * 분기하는 근거입니다. + * + * @return array{last_updated_at: ?string, progress: ?array, realtime_enabled: bool} */ public function getStatus(): array { @@ -129,6 +140,9 @@ class SitemapManager return [ 'last_updated_at' => $lastUpdatedAt !== '' ? $lastUpdatedAt : null, + 'progress' => $this->progress->get(), + 'realtime_enabled' => (bool) g7_core_settings('drivers.websocket_enabled', false) + && config('broadcasting.default') !== 'null', ]; } @@ -139,9 +153,15 @@ class SitemapManager */ private function rebuildStore(): void { + // 스트림 삽입 건수를 누적해 running 단계의 "누적 URL 수" 를 표기합니다(D4). + // countVisible() 같은 사전 count 쿼리 없이 삽입 결과만 합산하므로 1.4M 에서도 부담이 없습니다. + $total = 0; foreach ($this->generator->getContributors() as $contributor) { try { - $this->repository->replaceAllForContributor( + // 현재 기여자 진입 시점의 누적치(직전 기여자들까지)를 표시합니다. + $this->progress->phase($contributor->getIdentifier(), $total); + + $total += $this->repository->replaceAllForContributor( $contributor->getIdentifier(), $this->generator->streamContributorEntries($contributor), ); diff --git a/app/Seo/SitemapProgress.php b/app/Seo/SitemapProgress.php new file mode 100644 index 00000000..bec3f7a2 --- /dev/null +++ b/app/Seo/SitemapProgress.php @@ -0,0 +1,210 @@ +lastBroadcastUrls = 0; + + $this->persist([ + 'status' => 'queued', + 'mode' => $mode, + 'started_at' => now()->toIso8601String(), + 'finished_at' => null, + 'phase' => null, + 'urls' => 0, + 'url_count' => null, + 'child_count' => null, + 'message' => null, + ], broadcast: true); + } + + /** + * 현재 처리 중인 기여자 단계를 기록합니다. + * + * 단계가 바뀌면 즉시 방송하고, 같은 단계에서 urls 만 증가하면 간격 스로틀을 적용합니다. + * 캐시는 폴링 정확도를 위해 항상 기록합니다. + * + * @param string $phase 단계 식별자(기여자 identifier) + * @param int|null $urls 누적 URL 수(선택) + */ + public function phase(string $phase, ?int $urls = null): void + { + $state = $this->current(); + $phaseChanged = ($state['phase'] ?? null) !== $phase; + + $state['status'] = 'running'; + $state['phase'] = $phase; + if ($urls !== null) { + $state['urls'] = $urls; + } + + $broadcast = $phaseChanged + || ($urls !== null && ($urls - $this->lastBroadcastUrls) >= self::BROADCAST_URL_INTERVAL); + + $this->persist($state, broadcast: $broadcast); + + if ($broadcast) { + $this->lastBroadcastUrls = (int) ($state['urls'] ?? 0); + } + } + + /** + * 파일 작성 단계를 기록합니다. + * + * @param int|null $urls 작성 대상 누적 URL 수(선택) + */ + public function writing(?int $urls = null): void + { + $state = $this->current(); + $state['status'] = 'writing'; + $state['phase'] = null; + if ($urls !== null) { + $state['urls'] = $urls; + } + + $this->persist($state, broadcast: true); + } + + /** + * 재생성 완료를 기록합니다. + * + * @param array $meta 생성 메타(url_count, child_count 등) + */ + public function complete(array $meta): void + { + $state = $this->current(); + $state['status'] = 'completed'; + $state['phase'] = null; + $state['finished_at'] = now()->toIso8601String(); + $state['url_count'] = $meta['url_count'] ?? null; + $state['child_count'] = $meta['child_count'] ?? null; + $state['urls'] = $meta['url_count'] ?? ($state['urls'] ?? 0); + $state['message'] = null; + + $this->persist($state, broadcast: true); + } + + /** + * 재생성 실패를 기록합니다(무한 running 방지). + * + * @param string $message 실패 메시지 + */ + public function fail(string $message): void + { + $state = $this->current(); + $state['status'] = 'failed'; + $state['phase'] = null; + $state['finished_at'] = now()->toIso8601String(); + $state['message'] = $message; + + $this->persist($state, broadcast: true); + } + + /** + * 현재 진행상황을 반환합니다(폴링/초기 로드용). + * + * @return array|null 진행상황 또는 아직 실행 이력이 없으면 null + */ + public function get(): ?array + { + $state = $this->cache->get(self::KEY); + + return is_array($state) ? $state : null; + } + + /** + * 현재 진행상황을 반환하되, 없으면 기본 골격을 돌려줍니다(부분 갱신 병합용). + * + * @return array 진행상황 골격 + */ + private function current(): array + { + return $this->get() ?? [ + 'status' => 'idle', + 'mode' => null, + 'started_at' => null, + 'finished_at' => null, + 'phase' => null, + 'urls' => 0, + 'url_count' => null, + 'child_count' => null, + 'message' => null, + ]; + } + + /** + * 진행상황을 캐시에 기록하고, 필요 시 관리자 채널로 방송합니다. + * + * 방송 payload 는 상태 API 응답의 `data` 형태(`progress` + `realtime_enabled`)와 동형입니다. + * websocket 데이터소스가 `target_source` 로 대상 소스를 통째로 교체하므로, 동형이어야 + * 형제 필드(realtime_enabled)를 잃지 않고 UI 바인딩이 유지됩니다(D27). + * + * @param array $state 진행상황 상태 + * @param bool $broadcast 방송 여부(스로틀 판정 결과) + */ + private function persist(array $state, bool $broadcast): void + { + $this->cache->put(self::KEY, $state, self::TTL); + + if ($broadcast) { + HookManager::broadcast(self::CHANNEL, self::EVENT, [ + 'realtime_enabled' => true, + 'progress' => $state, + ]); + } + } +} diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md index 86888064..5af7356e 100644 --- a/docs/backend/api/seo.md +++ b/docs/backend/api/seo.md @@ -148,12 +148,13 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ +_단건 응답: `data` 객체의 필드 — `GET /api/admin/seo/sitemap/status` 와 동일한 스키마입니다._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| status | string | `dispatched` | 예약 상태. 재생성 잡이 큐에 등록되었음을 의미합니다 | | last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | **예약 시점 기준** 마지막 생성 일시. 이번 재생성의 결과가 아니라 직전 생성 시각이며, 잡 완료 후 갱신됩니다 (아직 한 번도 생성되지 않았으면 null) | +| progress | object\|null | `{"status":"queued","mode":"full",...}` | 재생성 진행상황. 예약 직후 `status=queued`, `mode=full` 로 즉시 기록됩니다. 필드 상세는 아래 상태 조회 API 참조 (진행 이력이 없으면 null) | +| realtime_enabled | boolean | `false` | 실시간 연결(WebSocket) 가능 여부. true 면 프론트가 진행상황 채널을 구독하고, false 면 상태 API 를 주기적으로 폴링합니다 | **응답 예시** @@ -166,8 +167,19 @@ HTTP/1.1 200 "success": true, "message": "Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.", "data": { - "status": "dispatched", - "last_updated_at": "2026-07-08T03:14:49+00:00" + "last_updated_at": "2026-07-08T03:14:49+00:00", + "progress": { + "status": "queued", + "mode": "full", + "started_at": "2026-07-08T05:20:00+00:00", + "finished_at": null, + "phase": null, + "urls": 0, + "url_count": null, + "child_count": null, + "message": null + }, + "realtime_enabled": false } } ``` @@ -184,11 +196,92 @@ HTTP/1.1 200 **설명** -sitemap.xml 재생성을 큐에 예약합니다. 응답은 예약 접수 결과이며 생성 완료를 의미하지 않습니다 — 실제 생성은 큐 워커가 `GenerateSitemapJob` 을 처리할 때 수행되고, 완료 후 마지막 생성 시각이 갱신됩니다. +sitemap.xml 재생성을 큐에 예약합니다. 관리자 수동 재생성은 현 생성 상태와 무관하게 **항상 전체(full) 재생성**입니다. 응답은 예약 접수 결과이며 생성 완료를 의미하지 않습니다 — 실제 생성은 큐 워커가 `GenerateSitemapJob` 을 처리할 때 수행되고, 완료 후 마지막 생성 시각이 갱신됩니다. 요청 스레드에서 동기 생성하지 않는 이유는 대용량(수백만 URL) 사이트에서 생성이 수 분~수십 분 걸려 관리자 요청이 메모리 초과/타임아웃으로 실패하기 때문입니다. 잡은 유니크 락(`seo-sitemap`)을 사용하므로 재생성을 연속으로 요청해도 동시에 여러 건이 실행되지 않고 한 건만 큐에 남습니다. -생성 진행 상황은 이 응답으로 알 수 없습니다. 완료 여부는 `last_updated_at` 이 갱신되었는지로 확인합니다. +예약 직후 진행상황이 `queued` 로 기록되며, 이후 진행 단계는 `GET /api/admin/seo/sitemap/status` 로 조회합니다. + +### GET /api/admin/seo/sitemap/status + +- **라우트명**: `api.admin.seo.sitemap.status` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@sitemapStatus` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/seo/sitemap/status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | 마지막 생성 일시 (아직 한 번도 생성되지 않았으면 null) | +| progress | object\|null | `{"status":"running",...}` | 재생성 진행상황. 진행 이력이 없으면 null | +| progress.status | string | `running` | `queued`(예약) / `running`(기여자 처리 중) / `writing`(파일 작성 중) / `completed`(완료) / `failed`(실패) | +| progress.mode | string\|null | `full` | 재생성 모드 (`full` / `auto` / `incremental`) | +| progress.phase | string\|null | `sirsoft-board` | 현재 처리 중인 기여자 식별자 (running 단계에서만) | +| progress.urls | integer | `12000` | 누적 처리 URL 수 | +| progress.url_count | integer\|null | `5000` | 완료 시 총 URL 수 | +| progress.child_count | integer\|null | `2` | 완료 시 분할 파일 수 | +| progress.started_at | string\|null | `2026-07-08T05:20:00+00:00` | 예약 시각 | +| progress.finished_at | string\|null | `2026-07-08T05:21:30+00:00` | 완료/실패 시각 | +| progress.message | string\|null | `null` | 실패 시 오류 메시지 | +| realtime_enabled | boolean | `false` | 실시간 연결(WebSocket) 가능 여부. true 면 프론트가 `core.admin.seo.sitemap` 채널을 구독하고, false 면 이 API 를 주기적으로 폴링합니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "last_updated_at": "2026-07-08T03:14:49+00:00", + "progress": { + "status": "running", + "mode": "full", + "started_at": "2026-07-08T05:20:00+00:00", + "finished_at": null, + "phase": "sirsoft-board", + "urls": 12000, + "url_count": null, + "child_count": null, + "message": null + }, + "realtime_enabled": false + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** + +Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다. SEO 탭 진입 시 초기 로드에 사용되며, `realtime_enabled` 가 false(웹소켓 미사용)일 때는 프론트가 진행 중(`queued`/`running`/`writing`) 동안 이 API 를 3초 간격으로 폴링합니다. 실시간 연결이 켜져 있으면 폴링 대신 `core.admin.seo.sitemap` 채널의 `sitemap.progress.updated` 이벤트로 즉시 갱신됩니다. + +진행상황은 캐시(TTL 3600초)에 저장되므로, 잡이 비정상 종료해도 시간이 지나면 만료됩니다. 잡 최종 실패 시에는 `progress.status=failed` 로 고정됩니다. ### GET /api/admin/seo/stats diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index d3753650..cd91fbd1 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -11,6 +11,7 @@ - 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. - 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. - 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. +- 환경설정 > SEO 화면의 Sitemap 생성 진행 상황 표시(대기·생성 중·파일 작성 중·완료·실패 단계, 현재 처리 항목, 누적 URL 수, 실시간/주기 확인 안내) 일본어 번역 추가 — 진행 상황 화면이 일본어 로케일에서도 올바르게 표시됩니다. ### Changed diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index fa7c4bb7..d38b1491 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1435,7 +1435,18 @@ "sitemap_gzip": "Sitemap 圧縮(gzip)", "sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。", "sitemap_hreflang_enabled": "多言語代替リンク (hreflang)", - "sitemap_hreflang_enabled_desc": "多言語サイトでURLごとに各言語バージョンを指す hreflang 代替リンクを挿入します。オフにするとリンクを省略してサイトマップサイズを削減します。" + "sitemap_hreflang_enabled_desc": "多言語サイトでURLごとに各言語バージョンを指す hreflang 代替リンクを挿入します。オフにするとリンクを省略してサイトマップサイズを削減します。", + "sitemap_regenerating": "作成中…", + "sitemap_progress": "作成進行状況", + "sitemap_status_queued": "待機中", + "sitemap_status_running": "作成中", + "sitemap_status_writing": "ファイル作成中", + "sitemap_status_completed": "完了", + "sitemap_status_failed": "失敗", + "sitemap_progress_phase": "現在処理中: {{phase}}", + "sitemap_progress_urls": "累積URL: {{count}}個", + "sitemap_realtime_connected": "リアルタイムで接続されています。", + "sitemap_polling": "進行状況を定期的に確認しています。" }, "security": { "title": "セキュリティ 設定", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json index 68e0fe21..12fbc873 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json @@ -1575,7 +1575,9 @@ "tokenValidation": "トークン検証", "user": "ユーザー", "users": "ユーザーリスト", - "dashboard_recent_notifications": "ダッシュボード最近の通知" + "dashboard_recent_notifications": "ダッシュボード最近の通知", + "sitemap_status": "サイトマップ生成ステータス", + "sitemap_progress_ws": "サイトマップ生成進行中(リアルタイム)" }, "array_group": { "chart_labels": "項目ラベル", diff --git a/routes/api.php b/routes/api.php index 0246435c..e678e4a8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -719,6 +719,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin' Route::post('clear-cache', [AdminSeoCacheController::class, 'clearCache'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.clear-cache'); Route::post('warmup', [AdminSeoCacheController::class, 'warmup'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.warmup'); Route::post('sitemap/regenerate', [AdminSeoCacheController::class, 'regenerateSitemap'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.sitemap.regenerate'); + Route::get('sitemap/status', [AdminSeoCacheController::class, 'sitemapStatus'])->middleware('permission:admin,core.settings.read')->name('api.admin.seo.sitemap.status'); Route::get('cached-urls', [AdminSeoCacheController::class, 'cachedUrls'])->middleware('permission:admin,core.settings.read')->name('api.admin.seo.cached-urls'); }); diff --git a/routes/channels.php b/routes/channels.php index ea11b7df..53b5548c 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -26,6 +26,11 @@ Broadcast::channel('core.admin.dashboard', function ($user) { return $user->hasPermission('core.dashboard.read'); }); +// 관리자 Sitemap 재생성 진행상황 채널 - core.settings.read 권한 필요 (SEO 탭 읽기 권한과 일치) +Broadcast::channel('core.admin.seo.sitemap', function ($user) { + return $user->hasPermission('core.settings.read'); +}); + // 사용자별 알림 채널 - UUID 기반 (User ID 노출 방지) // core.user-notifications.read (type=user) 권한 필요 Broadcast::channel('core.user.notifications.{uuid}', function ($user, $uuid) { diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index ffd388fd..53a63d5d 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -12,9 +12,13 @@ - SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다. - 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다. +### Added + +- SEO 설정에서 sitemap 생성 진행 상황을 화면에서 확인할 수 있습니다. 대기·생성 중·파일 작성 중·완료·실패 단계와 현재 처리 중인 항목, 누적 URL 수가 표시됩니다. 실시간 연결(WebSocket)이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다. + ### Changed -- SEO 설정의 "지금 생성" 버튼이 sitemap 생성을 예약하고 바로 응답합니다. 사이트 규모가 커도 화면이 멈추지 않으며, 생성 완료는 마지막 생성 시각으로 확인할 수 있습니다. +- SEO 설정의 "지금 생성" 버튼이 sitemap 생성을 예약하고 바로 응답합니다. 사이트 규모가 커도 화면이 멈추지 않으며, 생성 중에는 버튼이 비활성화되고 진행 상황이 화면에 표시됩니다. ## [1.0.3] - 2026-07-14 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 new file mode 100644 index 00000000..a946e165 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx @@ -0,0 +1,140 @@ +/** + * @file admin-settings-sitemap-progress.test.tsx + * @description 환경설정 > SEO > Sitemap 재생성 진행상황 UI 회귀 가드 (이슈 #79 / S5) + * + * 검증 포인트: + * - admin_settings.json: sitemap_status(api) + sitemap_progress_ws(websocket) 데이터소스 배선 + * - Reverb OFF(realtime_enabled!=true) + 진행 중 → startInterval 폴링 시작 + * - 완료/실패 전이 → stopInterval 폴링 중단 + * - websocket target_source 가 sitemap_status 로 수신 데이터를 병합(D27 동형 payload) + * - _tab_seo.json: 진행상황 표시 블록 + 재생성 버튼 재배선 + * - 상태 배지(대기/생성중/파일작성중/완료/실패) classMap + * - 실시간(구독) / 폴링 안내 문구 분기 + * - 버튼 disabled/label 이 progress.status 로 분기, 클릭 시 sitemap_status refetch + */ + +import { describe, it, expect } from 'vitest'; + +const layout = require('../../layouts/admin_settings.json'); +const seoTab = require('../../layouts/partials/admin_settings/_tab_seo.json'); + +interface AnyJson { [k: string]: any } + +/** + * 트리에서 조건에 맞는 노드를 모두 수집. + */ +function collectNodes(node: any, predicate: (n: any) => boolean): AnyJson[] { + const out: AnyJson[] = []; + const visit = (n: any) => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { n.forEach(visit); return; } + if (predicate(n)) out.push(n); + Object.values(n).forEach(visit); + }; + visit(node); + return out; +} + +const sources: AnyJson[] = layout.data_sources; +const byId = (id: string) => sources.find(s => s.id === id); + +describe('admin_settings — sitemap 진행상황 데이터소스', () => { + it('sitemap_status 는 seo 탭 게이트 + 상태 엔드포인트를 가진다', () => { + const s = byId('sitemap_status'); + expect(s).toBeTruthy(); + expect(s!.type).toBe('api'); + expect(s!.endpoint).toBe('/api/admin/seo/sitemap/status'); + expect(s!.method).toBe('GET'); + expect(typeof s!.if).toBe('string'); + expect(s!.if).toContain("'seo'"); + expect(s!.auto_fetch).not.toBe(false); + }); + + it('sitemap_status onSuccess: OFF + 진행중이면 startInterval 로 폴링', () => { + const s = byId('sitemap_status')!; + const start = (s.onSuccess as AnyJson[]).find(a => a.handler === 'startInterval'); + expect(start).toBeTruthy(); + // realtime OFF 조건 + 진행 상태 집합을 참조 + expect(start!.if).toContain('realtime_enabled'); + expect(start!.if).toContain('queued'); + expect(start!.if).toContain('running'); + expect(start!.if).toContain('writing'); + // 폴링 액션은 sitemap_status 재조회 + const refetch = (start!.params.actions as AnyJson[])[0]; + expect(refetch.handler).toBe('refetchDataSource'); + expect(refetch.params.dataSourceId).toBe('sitemap_status'); + // 같은 id 로 idempotent 등록 + expect(start!.params.id).toBe('sitemap_progress_poll'); + }); + + it('sitemap_status onSuccess: 완료/실패 전이면 stopInterval', () => { + const s = byId('sitemap_status')!; + const stop = (s.onSuccess as AnyJson[]).find(a => a.handler === 'stopInterval'); + expect(stop).toBeTruthy(); + expect(stop!.if).toContain('completed'); + expect(stop!.if).toContain('failed'); + expect(stop!.params.id).toBe('sitemap_progress_poll'); + }); + + it('sitemap_progress_ws 는 관리자 채널을 구독하고 sitemap_status 로 병합한다', () => { + const ws = byId('sitemap_progress_ws'); + expect(ws).toBeTruthy(); + expect(ws!.type).toBe('websocket'); + expect(ws!.channel).toBe('core.admin.seo.sitemap'); + expect(ws!.event).toBe('sitemap.progress.updated'); + expect(ws!.channel_type).toBe('private'); + expect(ws!.target_source).toBe('sitemap_status'); + }); +}); + +describe('_tab_seo — sitemap 진행상황 표시 UI', () => { + it('진행상황 블록은 상태 집합으로 게이트되고 progress 를 바인딩한다', () => { + const block = collectNodes(seoTab, n => n.id === 'sitemap_progress_block')[0]; + expect(block).toBeTruthy(); + expect(block.if).toContain('sitemap_status?.data?.progress?.status'); + expect(block.if).toContain('completed'); + expect(block.if).toContain('failed'); + }); + + it('상태 배지 classMap 이 5개 상태 variant 를 모두 정의한다', () => { + const badge = collectNodes(seoTab, n => n?.props?.classMap?.key + && String(n.props.classMap.key).includes('progress?.status'))[0]; + expect(badge).toBeTruthy(); + const variants = badge.props.classMap.variants; + ['queued', 'running', 'writing', 'completed', 'failed'].forEach(st => { + expect(variants[st]).toBeTruthy(); + }); + }); + + it('실시간(구독) / 폴링 안내 문구가 realtime_enabled 로 분기된다', () => { + const hint = collectNodes(seoTab, n => typeof n.text === 'string' + && n.text.includes('sitemap_realtime_connected') + && n.text.includes('sitemap_polling'))[0]; + expect(hint).toBeTruthy(); + expect(hint.text).toContain('realtime_enabled'); + }); + + it('현재 단계(phase)와 누적 URL 문구가 존재한다', () => { + const phase = collectNodes(seoTab, n => typeof n.text === 'string' + && n.text.includes('sitemap_progress_phase'))[0]; + const urls = collectNodes(seoTab, n => typeof n.text === 'string' + && n.text.includes('sitemap_progress_urls'))[0]; + expect(phase).toBeTruthy(); + expect(urls).toBeTruthy(); + }); + + it('재생성 버튼은 progress 상태로 disabled/label 분기하고 클릭 시 status 를 refetch 한다', () => { + const btn = collectNodes(seoTab, n => n.name === 'Button' + && typeof n?.props?.disabled === 'string' + && n.props.disabled.includes('progress?.status'))[0]; + expect(btn).toBeTruthy(); + expect(String(btn.text)).toContain('sitemap_regenerating'); + + const apiCall = (btn.actions as AnyJson[]).find(a => a.handler === 'apiCall'); + expect(apiCall).toBeTruthy(); + expect(apiCall!.target).toBe('/api/admin/seo/sitemap/regenerate'); + const refetch = (apiCall!.onSuccess as AnyJson[]).find(a => a.handler === 'refetchDataSource'); + expect(refetch).toBeTruthy(); + expect(refetch!.params.dataSourceId).toBe('sitemap_status'); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-tab-scoped-datasources.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-tab-scoped-datasources.test.tsx index 7757cfe2..4caacd7c 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-tab-scoped-datasources.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-tab-scoped-datasources.test.tsx @@ -29,6 +29,7 @@ const TAB_SCOPED: Record = { identityMessages: ['identity'], adminIdentityPolicies: ['identity'], policies: ['identity'], + sitemap_status: ['seo'], }; const sources: AnyJson[] = layout.data_sources; diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index e7791867..f2eeb7dd 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1431,7 +1431,18 @@ "sitemap_gzip": "Sitemap compression (gzip)", "sitemap_gzip_desc": "Compresses split sitemap files to reduce transfer size. The index file is not compressed.", "sitemap_hreflang_enabled": "Multilingual alternate links (hreflang)", - "sitemap_hreflang_enabled_desc": "Adds hreflang alternate links pointing to each language version of a URL on multilingual sites. Turn off to omit the links and reduce sitemap size." + "sitemap_hreflang_enabled_desc": "Adds hreflang alternate links pointing to each language version of a URL on multilingual sites. Turn off to omit the links and reduce sitemap size.", + "sitemap_regenerating": "Generating…", + "sitemap_progress": "Generation progress", + "sitemap_status_queued": "Queued", + "sitemap_status_running": "Generating", + "sitemap_status_writing": "Writing files", + "sitemap_status_completed": "Completed", + "sitemap_status_failed": "Failed", + "sitemap_progress_phase": "Processing: {{phase}}", + "sitemap_progress_urls": "URLs so far: {{count}}", + "sitemap_realtime_connected": "Connected in real time.", + "sitemap_polling": "Checking progress periodically." }, "security": { "title": "Security Settings", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/editor.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/editor.json index a4b4f9ac..870ea8d2 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/editor.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/editor.json @@ -1646,6 +1646,8 @@ "schedule_history": "Schedule History", "schedules": "Schedules", "settings": "Settings", + "sitemap_status": "Sitemap Generation Status", + "sitemap_progress_ws": "Sitemap Generation Progress (Live)", "systemInfo": "System Info", "templateDetail": "Template Detail", "templateInstallPreview": "Template Install Preview", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 415e2d8d..ced745e3 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1435,7 +1435,18 @@ "sitemap_gzip": "Sitemap 압축 (gzip)", "sitemap_gzip_desc": "나뉜 sitemap 파일을 압축해 전송량을 줄입니다. 색인 파일은 압축하지 않습니다.", "sitemap_hreflang_enabled": "다국어 대체 링크 (hreflang)", - "sitemap_hreflang_enabled_desc": "다국어 사이트에서 URL 마다 각 언어 버전을 가리키는 hreflang 대체 링크를 넣습니다. 끄면 링크를 생략해 sitemap 크기를 줄입니다." + "sitemap_hreflang_enabled_desc": "다국어 사이트에서 URL 마다 각 언어 버전을 가리키는 hreflang 대체 링크를 넣습니다. 끄면 링크를 생략해 sitemap 크기를 줄입니다.", + "sitemap_regenerating": "생성 중…", + "sitemap_progress": "생성 진행 상황", + "sitemap_status_queued": "대기 중", + "sitemap_status_running": "생성 중", + "sitemap_status_writing": "파일 작성 중", + "sitemap_status_completed": "완료", + "sitemap_status_failed": "실패", + "sitemap_progress_phase": "현재 처리 중: {{phase}}", + "sitemap_progress_urls": "누적 URL: {{count}}개", + "sitemap_realtime_connected": "실시간으로 연결되어 있습니다.", + "sitemap_polling": "진행 상황을 주기적으로 확인하고 있습니다." }, "security": { "title": "보안 설정", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/editor.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/editor.json index b54cb638..5e239ca8 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/editor.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/editor.json @@ -1646,6 +1646,8 @@ "schedule_history": "스케줄 이력", "schedules": "스케줄 목록", "settings": "환경설정", + "sitemap_status": "Sitemap 생성 상태", + "sitemap_progress_ws": "Sitemap 생성 진행 (실시간)", "systemInfo": "시스템 정보", "templateDetail": "템플릿 상세", "templateInstallPreview": "템플릿 설치 미리보기", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json index 1a749e56..c7564690 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json @@ -265,6 +265,57 @@ "meta": {} } } + }, + { + "id": "sitemap_status", + "label_key": "$t:editor.data_source.sitemap_status", + "type": "api", + "endpoint": "/api/admin/seo/sitemap/status", + "method": "GET", + "if": "{{(query.tab || _global.activeSettingsTab || 'general') === 'seo'}}", + "auto_fetch": true, + "auth_required": true, + "fallback": { + "data": { + "last_updated_at": null, + "progress": null, + "realtime_enabled": false + } + }, + "onSuccess": [ + { + "handler": "startInterval", + "if": "{{sitemap_status?.data?.realtime_enabled !== true && ['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '')}}", + "params": { + "id": "sitemap_progress_poll", + "intervalMs": 3000, + "actions": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "sitemap_status" + } + } + ] + } + }, + { + "handler": "stopInterval", + "if": "{{['completed','failed'].includes(sitemap_status?.data?.progress?.status ?? '')}}", + "params": { + "id": "sitemap_progress_poll" + } + } + ] + }, + { + "id": "sitemap_progress_ws", + "label_key": "$t:editor.data_source.sitemap_progress_ws", + "type": "websocket", + "channel": "core.admin.seo.sitemap", + "event": "sitemap.progress.updated", + "channel_type": "private", + "target_source": "sitemap_status" } ], "slots": { @@ -310,7 +361,7 @@ "name": "Icon", "props": { "name": "refresh", - "className": "w-5 h-5" + "className": "text-xl" } } ] @@ -338,7 +389,7 @@ "name": "Icon", "props": { "name": "lock", - "className": "w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" + "className": "text-xl text-yellow-600 dark:text-yellow-400 flex-shrink-0" } }, { @@ -512,7 +563,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } }, { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json index 15bac572..db911063 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json @@ -1078,6 +1078,87 @@ } ] }, + { + "id": "sitemap_progress_block", + "type": "basic", + "name": "Div", + "if": "{{['queued','running','writing','completed','failed'].includes(sitemap_status?.data?.progress?.status ?? '')}}", + "props": { + "className": "space-y-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-heading" + }, + "text": "$t:admin.settings.seo.sitemap_progress" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "px-2 py-0.5 rounded text-xs font-medium", + "classMap": { + "key": "{{sitemap_status?.data?.progress?.status ?? ''}}", + "variants": { + "queued": "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200", + "running": "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200", + "writing": "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200", + "completed": "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200", + "failed": "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200" + } + } + }, + "text": "{{(sitemap_status?.data?.progress?.status === 'queued' && $t('admin.settings.seo.sitemap_status_queued')) || (sitemap_status?.data?.progress?.status === 'running' && $t('admin.settings.seo.sitemap_status_running')) || (sitemap_status?.data?.progress?.status === 'writing' && $t('admin.settings.seo.sitemap_status_writing')) || (sitemap_status?.data?.progress?.status === 'completed' && $t('admin.settings.seo.sitemap_status_completed')) || (sitemap_status?.data?.progress?.status === 'failed' && $t('admin.settings.seo.sitemap_status_failed')) || ''}}" + } + ] + }, + { + "type": "basic", + "name": "P", + "if": "{{sitemap_status?.data?.progress?.status === 'running' && (sitemap_status?.data?.progress?.phase ?? '') !== ''}}", + "props": { + "className": "text-label-subtle text-sm" + }, + "text": "$t:admin.settings.seo.sitemap_progress_phase|phase={{sitemap_status?.data?.progress?.phase ?? ''}}" + }, + { + "type": "basic", + "name": "P", + "if": "{{Number(sitemap_status?.data?.progress?.urls ?? 0) > 0}}", + "props": { + "className": "text-label-subtle text-sm" + }, + "text": "$t:admin.settings.seo.sitemap_progress_urls|count={{sitemap_status?.data?.progress?.urls ?? 0}}" + }, + { + "type": "basic", + "name": "P", + "if": "{{sitemap_status?.data?.progress?.status === 'failed' && (sitemap_status?.data?.progress?.message ?? '') !== ''}}", + "props": { + "className": "text-red-600 dark:text-red-400 text-sm break-words" + }, + "text": "{{sitemap_status?.data?.progress?.message ?? ''}}" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle text-xs" + }, + "text": "{{sitemap_status?.data?.realtime_enabled === true ? $t('admin.settings.seo.sitemap_realtime_connected') : $t('admin.settings.seo.sitemap_polling')}}" + } + ] + }, { "id": "sitemap_last_updated_block", "type": "basic", @@ -1107,7 +1188,7 @@ "props": { "type": "text", "disabled": true, - "value": "{{_local.form?.seo?.sitemap_last_updated_at ?? '-'}}", + "value": "{{sitemap_status?.data?.last_updated_at ?? _local.form?.seo?.sitemap_last_updated_at ?? '-'}}", "className": "md:flex-1 dark:bg-gray-900 text-gray-700 dark:text-gray-300" } }, @@ -1116,63 +1197,41 @@ "name": "Button", "props": { "type": "button", - "disabled": "{{_computed.isReadOnly || _local.sitemapUpdating === true}}", + "disabled": "{{_computed.isReadOnly || ['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '')}}", "className": "w-full md:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap" }, - "text": "{{_local.sitemapUpdating === true ? $t('admin.settings.seo.sitemap_updating') : $t('admin.settings.seo.sitemap_manual_update')}}", + "text": "{{['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '') ? $t('admin.settings.seo.sitemap_regenerating') : $t('admin.settings.seo.sitemap_manual_update')}}", "actions": [ { "type": "click", - "handler": "setState", + "handler": "apiCall", + "auth_required": true, + "target": "/api/admin/seo/sitemap/regenerate", "params": { - "target": "local", - "sitemapUpdating": true + "method": "POST" }, "onSuccess": [ { - "handler": "apiCall", - "auth_required": true, - "target": "/api/admin/seo/sitemap/regenerate", + "handler": "refetchDataSource", "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "setState", - "params": { - "target": "local", - "sitemapUpdating": false, - "form": { - "seo": { - "sitemap_last_updated_at": "{{response.data?.last_updated_at ?? ''}}" - } - } - } - }, - { - "handler": "toast", - "params": { - "type": "success", - "message": "{{$t('admin.settings.seo.sitemap_update_success')}}" - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "local", - "sitemapUpdating": false - } - }, - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.message ?? $t('admin.settings.seo.sitemap_update_failed')}}" - } - } - ] + "dataSourceId": "sitemap_status" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "{{$t('admin.settings.seo.sitemap_update_success')}}" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message ?? $t('admin.settings.seo.sitemap_update_failed')}}" + } } ] } diff --git a/tests/Feature/Api/Admin/SeoCacheControllerTest.php b/tests/Feature/Api/Admin/SeoCacheControllerTest.php index 3720cf6d..598d6e96 100644 --- a/tests/Feature/Api/Admin/SeoCacheControllerTest.php +++ b/tests/Feature/Api/Admin/SeoCacheControllerTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature\Api\Admin; use App\Enums\ExtensionOwnerType; +use App\Enums\SitemapGenerationMode; use App\Jobs\GenerateSitemapJob; +use App\Seo\SitemapProgress; use App\Models\Permission; use App\Models\Role; use App\Models\SeoCacheStat; @@ -392,27 +394,33 @@ class SeoCacheControllerTest extends TestCase } /** - * 재생성 요청 시 200 + 예약 상태와 직전 생성 시각을 반환 + * 재생성 요청 시 200 + 진행상황(queued)과 직전 생성 시각을 반환 */ - public function test_regenerate_sitemap_returns_200_with_dispatched_status(): void + public function test_regenerate_sitemap_returns_200_with_queued_progress(): void { Queue::fake(); Config::set('g7_settings.core.seo.sitemap_enabled', true); Config::set('g7_settings.core.seo.sitemap_last_updated_at', '2026-04-29T10:00:00+00:00'); + // realtime_enabled 판정을 결정적으로: 웹소켓 OFF → false + Config::set('broadcasting.default', 'null'); $response = $this->withHeader('Authorization', 'Bearer '.$this->token) ->postJson('/api/admin/seo/sitemap/regenerate'); $response->assertStatus(200); $this->assertTrue($response->json('success')); - $this->assertSame('dispatched', $response->json('data.status')); + $this->assertSame('queued', $response->json('data.progress.status')); + $this->assertSame('full', $response->json('data.progress.mode')); $this->assertSame('2026-04-29T10:00:00+00:00', $response->json('data.last_updated_at')); + $this->assertFalse($response->json('data.realtime_enabled')); } /** - * 재생성 요청이 큐 잡을 1건만 디스패치하는지 확인 + * 재생성 요청이 큐 잡을 1건만, 항상 전체(Full) 모드로 디스패치하는지 확인 + * + * 관리자 수동 재생성은 현 생성 상태와 무관하게 항상 전량 재생성입니다(D7). */ - public function test_regenerate_sitemap_dispatches_job(): void + public function test_regenerate_sitemap_dispatches_full_mode_job(): void { Queue::fake(); Config::set('g7_settings.core.seo.sitemap_enabled', true); @@ -422,6 +430,10 @@ class SeoCacheControllerTest extends TestCase ->assertStatus(200); Queue::assertPushed(GenerateSitemapJob::class, 1); + Queue::assertPushed( + GenerateSitemapJob::class, + fn (GenerateSitemapJob $job) => $job->mode === SitemapGenerationMode::Full + ); } /** @@ -460,6 +472,87 @@ class SeoCacheControllerTest extends TestCase Queue::assertNotPushed(GenerateSitemapJob::class); } + // ======================================================================== + // Sitemap 진행상황 조회 (sitemapStatus) + // ======================================================================== + + /** + * 인증 없이 sitemap 상태 조회 시 401 반환 + */ + public function test_sitemap_status_returns_401_without_authentication(): void + { + $response = $this->getJson('/api/admin/seo/sitemap/status'); + + $response->assertStatus(401); + } + + /** + * read 권한만 있어도 sitemap 상태 조회 가능 (SEO 탭 읽기 권한) + */ + public function test_sitemap_status_allows_read_permission(): void + { + $user = $this->createAdminUser(['core.settings.read']); + $token = $user->createToken('test-token')->plainTextToken; + + $response = $this->withToken($token)->getJson('/api/admin/seo/sitemap/status'); + + $response->assertStatus(200); + } + + /** + * 상태 조회 응답이 진행상황 + realtime_enabled 스키마를 포함 + */ + public function test_sitemap_status_returns_progress_and_realtime_flag(): void + { + // 진행상황을 미리 기록 + app(SitemapProgress::class)->start(SitemapGenerationMode::Full->value); + // realtime_enabled 판정을 결정적으로: 웹소켓 OFF → false + Config::set('broadcasting.default', 'null'); + + $response = $this->withToken($this->token)->getJson('/api/admin/seo/sitemap/status'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'success', + 'data' => [ + 'last_updated_at', + 'progress' => ['status', 'mode'], + 'realtime_enabled', + ], + ]); + $this->assertSame('queued', $response->json('data.progress.status')); + $this->assertFalse($response->json('data.realtime_enabled')); + } + + /** + * 진행상황이 없으면 progress 는 null + */ + public function test_sitemap_status_returns_null_progress_when_idle(): void + { + $response = $this->withToken($this->token)->getJson('/api/admin/seo/sitemap/status'); + + $response->assertStatus(200); + $this->assertNull($response->json('data.progress')); + } + + /** + * 재생성 요청이 진행상황을 'queued' 로 즉시 기록 (큐 대기 중에도 UI 표시) + */ + public function test_regenerate_sitemap_records_queued_progress(): void + { + Queue::fake(); + Config::set('g7_settings.core.seo.sitemap_enabled', true); + + $this->withHeader('Authorization', 'Bearer '.$this->token) + ->postJson('/api/admin/seo/sitemap/regenerate') + ->assertStatus(200); + + $progress = app(SitemapProgress::class)->get(); + $this->assertNotNull($progress); + $this->assertSame('queued', $progress['status']); + $this->assertSame('full', $progress['mode']); + } + protected function tearDown(): void { Mockery::close(); diff --git a/tests/Feature/Api/BroadcastingAuthTest.php b/tests/Feature/Api/BroadcastingAuthTest.php index c05bab28..721119b5 100644 --- a/tests/Feature/Api/BroadcastingAuthTest.php +++ b/tests/Feature/Api/BroadcastingAuthTest.php @@ -2,6 +2,9 @@ namespace Tests\Feature\Api; +use App\Enums\ExtensionOwnerType; +use App\Models\Permission; +use App\Models\Role; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; @@ -74,4 +77,93 @@ class BroadcastingAuthTest extends TestCase // 토글 OFF 가 아님을 확인: 401(미인증) 이 아닌 것으로 인증 통과 입증. $this->assertNotSame(401, $response->getStatusCode()); } + + /** + * Sitemap 진행상황 채널이 등록되어 있고, core.settings.read 권한 보유 시 인증을 허용한다. + * + * broadcasting/auth HTTP 엔드포인트는 이 하네스에서 서명 응답을 낼 수 없어(권한 유무와 무관하게 403) + * 인증 여부를 구분하지 못하므로, 등록된 채널 콜백을 직접 호출해 인가 로직을 검증한다. + */ + public function test_sitemap_channel_authorizes_user_with_settings_read(): void + { + $callback = $this->channelCallback('core.admin.seo.sitemap'); + + $user = $this->userWithPermission('core.settings.read'); + + $this->assertTrue((bool) $callback($user)); + } + + /** + * Sitemap 진행상황 채널: core.settings.read 권한 미보유 시 거부. + */ + public function test_sitemap_channel_rejects_user_without_permission(): void + { + $callback = $this->channelCallback('core.admin.seo.sitemap'); + + $user = User::factory()->create(); + + $this->assertFalse((bool) $callback($user)); + } + + /** + * 등록된 브로드캐스트 채널의 인가 콜백을 반환합니다. + * + * @param string $channel 채널명 (private- 접두 없이) + * @return \Closure 인가 콜백 (첫 인자 = 사용자) + */ + private function channelCallback(string $channel): \Closure + { + $broadcaster = app(\Illuminate\Contracts\Broadcasting\Factory::class)->connection(); + + $ref = new \ReflectionClass($broadcaster); + while ($ref !== false && ! $ref->hasProperty('channels')) { + $ref = $ref->getParentClass(); + } + $this->assertNotFalse($ref, 'broadcaster 에 channels 프로퍼티가 없음'); + + $prop = $ref->getProperty('channels'); + $prop->setAccessible(true); + $channels = $prop->getValue($broadcaster); + + $this->assertArrayHasKey($channel, $channels, "채널이 등록되지 않음: {$channel}"); + + return $channels[$channel]; + } + + /** + * 지정한 관리자 권한을 가진 사용자를 생성합니다. + * + * @param string $permission 권한 식별자 + * @return User 권한이 부여된 사용자 + */ + private function userWithPermission(string $permission): User + { + $user = User::factory()->create(); + + $perm = Permission::firstOrCreate( + ['identifier' => $permission], + [ + 'name' => json_encode(['ko' => $permission, 'en' => $permission]), + 'description' => json_encode(['ko' => $permission, 'en' => $permission]), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => 'admin', + ] + ); + + $role = Role::create([ + 'identifier' => 'sitemap_channel_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'description' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'is_active' => true, + ]); + $role->permissions()->sync([$perm->id]); + + $user->roles()->attach($role->id, [ + 'assigned_at' => now(), + 'assigned_by' => null, + ]); + + return $user->fresh(); + } } diff --git a/tests/Feature/Seo/SitemapManagerModeTest.php b/tests/Feature/Seo/SitemapManagerModeTest.php index d039bfd3..1a4dd793 100644 --- a/tests/Feature/Seo/SitemapManagerModeTest.php +++ b/tests/Feature/Seo/SitemapManagerModeTest.php @@ -97,6 +97,7 @@ class SitemapManagerModeTest extends TestCase Mockery::spy(ConfigRepositoryInterface::class), $this->storage, $this->repository, + $this->app->make(\App\Seo\SitemapProgress::class), ); } diff --git a/tests/Feature/Seo/SitemapUrlRepositoryTest.php b/tests/Feature/Seo/SitemapUrlRepositoryTest.php index dff42503..de39f1d3 100644 --- a/tests/Feature/Seo/SitemapUrlRepositoryTest.php +++ b/tests/Feature/Seo/SitemapUrlRepositoryTest.php @@ -156,6 +156,30 @@ class SitemapUrlRepositoryTest extends TestCase $this->assertSame(['https://example.com/page/a'], $page, '다른 기여자 행은 보존돼야 합니다.'); } + /** + * replaceAllForContributor 는 삽입된 행 수를 반환한다 (진행상황 누적 URL 표기용). + * + * 청크 경계를 넘겨도 정확히 집계돼야 한다 (INSERT_CHUNK=1000 이므로 2건은 단일 청크지만 + * 반환 계약 자체를 고정한다). + */ + public function test_replace_all_for_contributor_returns_inserted_count(): void + { + $count = $this->repository->replaceAllForContributor('sirsoft-board', [ + ['loc' => 'https://example.com/board/a', 'resource_type' => 'board', 'resource_id' => '1'], + ['loc' => 'https://example.com/board/a/1', 'resource_type' => 'board_post', 'resource_id' => '1'], + ['loc' => 'https://example.com/board/a/2', 'resource_type' => 'board_post', 'resource_id' => '2'], + ]); + + $this->assertSame(3, $count); + + // 재대체 시에도 새로 삽입된 건수를 반환 (기존 삭제분은 제외) + $recount = $this->repository->replaceAllForContributor('sirsoft-board', [ + ['loc' => 'https://example.com/board/b', 'resource_type' => 'board', 'resource_id' => '2'], + ]); + + $this->assertSame(1, $recount); + } + /** * 긴 loc(2000자) 도 loc_hash 로 안전 저장 및 식별된다 (MySQL 키 길이 회피). */ 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 8e370b9c..f61a297a 100644 --- a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -78,11 +78,14 @@ test('#481 - hreflang 토글을 바꿔 저장하면 성공하고 재진입 시 const toggle = page.locator(`${HREFLANG_ROW} input[type="checkbox"]`).first(); await expect(toggle).toBeAttached({ timeout: 15_000 }); + // 체크박스 input 은 sr-only(화면에서 숨김)이고 클릭 대상은 wrapper div(onClick) 이다. + // input 을 직접 클릭하면 toggle-switch-track 오버레이가 포인터 이벤트를 가로챈다. + const toggleSwitch = page.locator(`${HREFLANG_ROW} .toggle-switch-wrapper`).first(); const wasChecked = await toggle.isChecked(); // 토글을 반대 상태로 바꾼다 - await toggle.click(); + await toggleSwitch.click(); await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); const save = page.waitForResponse( @@ -99,7 +102,7 @@ test('#481 - hreflang 토글을 바꿔 저장하면 성공하고 재진입 시 await expect.poll(() => reloaded.isChecked(), { timeout: 15_000 }).toBe(!wasChecked); // 원상 복구 (E2E 는 실제 환경 설정을 건드린다) - await reloaded.click(); + await page.locator(`${HREFLANG_ROW} .toggle-switch-wrapper`).first().click(); const restore = page.waitForResponse( (r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST', { timeout: 20_000 } @@ -197,8 +200,8 @@ test('#481 - 고급 탭 Sitemap 캐시 기준값 칸이 마운트되고 기본 }); // @scenario tab=seo, permitted=yes -// @effects regenerate_is_async -test('#481 - "지금 생성" 클릭이 큐에 예약되고 즉시 응답한다', async ({ page }) => { +// @effects regenerate_is_async, progress_visible +test('#481 - "지금 생성" 클릭이 큐에 예약되고 진행상황이 화면에 표시된다', async ({ page }) => { const token = issueToken('core.settings.read', 'core.settings.update'); await authenticatePage(page, token); @@ -215,9 +218,39 @@ test('#481 - "지금 생성" 클릭이 큐에 예약되고 즉시 응답한다', const elapsed = Date.now() - startedAt; expect(response.status()).toBe(200); - expect((await response.json())?.data?.status).toBe('dispatched'); + // 예약 즉시 진행상황이 'queued' 로 실려 온다 (동기 생성이 아니라 큐 예약) + expect((await response.json())?.data?.progress?.status).toBe('queued'); // 동기 생성으로 되돌아가면 응답 시간이 데이터 규모에 비례해 늘어난다. // 절대 하한 대신 "예약 응답이라면 당연히 만족하는" 상한으로 회귀만 잡는다. expect(elapsed).toBeLessThan(10_000); + + // 진행상황 블록이 화면에 나타나고 상태 배지가 미해석 키가 아닌 실제 문구로 렌더된다 + const progressBlock = page.locator('#sitemap_progress_block'); + await expect(progressBlock).toBeAttached({ timeout: 15_000 }); + const progressText = (await progressBlock.innerText()).trim(); + expect(progressText).not.toContain('$t:'); + expect(progressText.length).toBeGreaterThan(0); +}); + +// @scenario tab=seo, permitted=yes +// @effects status_endpoint +test('#481 - SEO 탭 진입 시 sitemap 상태 API 가 진행상황/실시간 여부를 반환한다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + const status = page.waitForResponse( + (r) => r.url().includes('/api/admin/seo/sitemap/status') && r.request().method() === 'GET', + { timeout: 20_000 } + ); + + await gotoSeoTab(page); + + const res = await status; + expect(res.status()).toBe(200); + const body = await res.json(); + // 상태 응답 스키마: 진행상황(progress) + 실시간 연결 가능 여부(realtime_enabled) + expect(body?.data).toHaveProperty('realtime_enabled'); + expect(body?.data).toHaveProperty('progress'); + expect(body?.data).toHaveProperty('last_updated_at'); }); diff --git a/tests/Unit/Seo/SitemapManagerTest.php b/tests/Unit/Seo/SitemapManagerTest.php index 5673c09c..ac02bb5e 100644 --- a/tests/Unit/Seo/SitemapManagerTest.php +++ b/tests/Unit/Seo/SitemapManagerTest.php @@ -133,6 +133,7 @@ class SitemapManagerTest extends TestCase $config ?? Mockery::spy(ConfigRepositoryInterface::class), $this->storage, $this->app->make(SitemapUrlRepositoryInterface::class), + $this->app->make(\App\Seo\SitemapProgress::class), ); } @@ -312,6 +313,7 @@ class SitemapManagerTest extends TestCase Mockery::spy(ConfigRepositoryInterface::class), $this->storage, $this->incrementalRepository(), + $this->app->make(\App\Seo\SitemapProgress::class), ); $result = $manager->regenerate(); @@ -343,6 +345,7 @@ class SitemapManagerTest extends TestCase Mockery::spy(ConfigRepositoryInterface::class), $this->storage, $this->incrementalRepository(), + $this->app->make(\App\Seo\SitemapProgress::class), ); $this->assertFalse($manager->regenerate()['success']); diff --git a/tests/Unit/Seo/SitemapProgressTest.php b/tests/Unit/Seo/SitemapProgressTest.php new file mode 100644 index 00000000..90feda5e --- /dev/null +++ b/tests/Unit/Seo/SitemapProgressTest.php @@ -0,0 +1,165 @@ +progress = app(SitemapProgress::class); + } + + /** + * Reverb 방송이 실제로 발생하도록 드라이버/호스트를 설정합니다. + */ + private function enableReverb(): void + { + config(['broadcasting.default' => 'reverb']); + config(['broadcasting.connections.reverb.options.host' => 'localhost']); + } + + /** + * 실행 이력이 없으면 get() 은 null 을 반환한다. + */ + public function test_get_returns_null_when_no_run(): void + { + $this->assertNull($this->progress->get()); + } + + /** + * start() 는 status=queued 와 mode/started_at 을 기록한다. + */ + public function test_start_records_queued_state(): void + { + $this->progress->start('full'); + + $state = $this->progress->get(); + $this->assertSame('queued', $state['status']); + $this->assertSame('full', $state['mode']); + $this->assertNotNull($state['started_at']); + $this->assertSame(0, $state['urls']); + } + + /** + * phase() 는 status=running 과 현재 단계를 기록한다. + */ + public function test_phase_records_running_state(): void + { + $this->progress->start('full'); + $this->progress->phase('sirsoft-board', 1200); + + $state = $this->progress->get(); + $this->assertSame('running', $state['status']); + $this->assertSame('sirsoft-board', $state['phase']); + $this->assertSame(1200, $state['urls']); + } + + /** + * writing() 은 status=writing 을 기록한다. + */ + public function test_writing_records_writing_state(): void + { + $this->progress->start('full'); + $this->progress->writing(3400); + + $state = $this->progress->get(); + $this->assertSame('writing', $state['status']); + $this->assertSame(3400, $state['urls']); + } + + /** + * complete() 는 status=completed 와 url_count/child_count 를 기록한다. + */ + public function test_complete_records_completed_state(): void + { + $this->progress->start('full'); + $this->progress->complete(['url_count' => 5000, 'child_count' => 2]); + + $state = $this->progress->get(); + $this->assertSame('completed', $state['status']); + $this->assertSame(5000, $state['url_count']); + $this->assertSame(2, $state['child_count']); + $this->assertNotNull($state['finished_at']); + } + + /** + * fail() 은 status=failed 와 메시지를 기록한다. + */ + public function test_fail_records_failed_state(): void + { + $this->progress->start('full'); + $this->progress->fail('boom'); + + $state = $this->progress->get(); + $this->assertSame('failed', $state['status']); + $this->assertSame('boom', $state['message']); + $this->assertNotNull($state['finished_at']); + } + + /** + * Reverb ON 이면 방송 payload 가 상태 API data 형태(progress + realtime_enabled)와 동형이다. + */ + public function test_broadcast_payload_mirrors_status_shape(): void + { + $this->enableReverb(); + Event::fake([GenericBroadcastEvent::class]); + + $this->progress->start('full'); + + Event::assertDispatched(GenericBroadcastEvent::class, function ($event) { + return $event->channel === 'core.admin.seo.sitemap' + && $event->eventName === 'sitemap.progress.updated' + && ($event->payload['realtime_enabled'] ?? null) === true + && ($event->payload['progress']['status'] ?? null) === 'queued'; + }); + } + + /** + * Reverb OFF 여도 캐시는 기록된다(폴링 폴백 성립). + */ + public function test_cache_is_written_even_when_reverb_off(): void + { + config(['broadcasting.default' => 'null']); + Event::fake([GenericBroadcastEvent::class]); + + $this->progress->start('full'); + + Event::assertNotDispatched(GenericBroadcastEvent::class); + $this->assertSame('queued', $this->progress->get()['status']); + } + + /** + * 같은 단계에서 urls 만 증가하면 방송은 간격 스로틀되지만 캐시는 항상 기록된다. + */ + public function test_phase_broadcast_is_throttled_but_cache_always_written(): void + { + $this->enableReverb(); + Event::fake([GenericBroadcastEvent::class]); + + // 단계 진입 — 방송 1회 + $this->progress->phase('sirsoft-board', 100); + // 같은 단계, 소폭 증가(<5000) — 방송 스킵, 캐시는 갱신 + $this->progress->phase('sirsoft-board', 200); + // 같은 단계, 간격 초과(>=5000) — 방송 재발 + $this->progress->phase('sirsoft-board', 5300); + + // 방송은 진입 + 간격초과 = 2회 + Event::assertDispatchedTimes(GenericBroadcastEvent::class, 2); + // 캐시는 마지막 값으로 항상 갱신 + $this->assertSame(5300, $this->progress->get()['urls']); + } +}