feat(seo): 사이트맵 재생성 진행상황 + 실시간/폴링 UI
관리자 "전체 재생성"을 큐(Full 모드)로 던지고 진행상황을 SEO 탭에서 확인한다. Reverb ON 이면 방송 채널 구독으로 실시간 갱신, OFF 면 상태 API 폴링으로 폴백한다. - SitemapProgress 진행상황 스토어(queued→running→writing→completed/failed, 캐시 TTL 3600, 방송 스로틀) + SitemapManager.getStatus 확장(progress + realtime_enabled) - GET /api/admin/seo/sitemap/status(core.settings.read) 신규 - 관리자 재생성을 항상 Full 모드로 큐잉 + 진행률 기록, Job 최종 실패 시 progress=failed 고정 - core.admin.seo.sitemap 방송 채널 인증(core.settings.read) - SEO 탭 진행상황 UI(상태 배지/단계/누적 URL/실시간·폴링 안내) + 폴링 분기 - 재생성 중 누적 URL 표기(replaceAllForContributor 삽입 건수 반환 — count 쿼리 없이 스트림 누적) - ja 언어팩 진행상황 UI 번역 동기화
This commit is contained in:
@@ -56,6 +56,7 @@ interface SitemapUrlRepositoryInterface
|
||||
*
|
||||
* @param string $contributor 기여자 식별자
|
||||
* @param iterable<int, array{loc: string, resource_type?: string, resource_id?: mixed, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목 스트림
|
||||
* @return int 삽입된 행 수 (진행상황 누적 URL 표기용 — count 쿼리 없이 스트림 누적)
|
||||
*/
|
||||
public function replaceAllForContributor(string $contributor, iterable $entries): void;
|
||||
public function replaceAllForContributor(string $contributor, iterable $entries): int;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다.
|
||||
|
||||
@@ -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<string, mixed>, 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),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Seo;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\HookManager;
|
||||
|
||||
/**
|
||||
* Sitemap 재생성 진행상황 스토어.
|
||||
*
|
||||
* 재생성 잡/커맨드가 단계(phase)를 기록하면 캐시(seo.sitemap.progress)에 저장하고,
|
||||
* Reverb 가 활성일 때는 관리자 채널로 방송해 SEO 탭이 실시간으로 갱신되게 합니다.
|
||||
* Reverb OFF 시 방송은 HookManager 가 자동으로 건너뛰며(분기 불필요), 캐시는 항상
|
||||
* 기록되므로 프론트가 폴링으로 폴백할 수 있습니다.
|
||||
*
|
||||
* 상태 전이: queued → running(기여자별) → writing → completed | failed
|
||||
* (진행바는 퍼센트가 아닌 단계 기반 — 사전 count 쿼리가 1.4M 에서 부담이므로.)
|
||||
*/
|
||||
final class SitemapProgress
|
||||
{
|
||||
/**
|
||||
* 진행상황 캐시 키
|
||||
*/
|
||||
public const KEY = 'seo.sitemap.progress';
|
||||
|
||||
/**
|
||||
* 방송 채널명 (관리자 전용)
|
||||
*/
|
||||
public const CHANNEL = 'core.admin.seo.sitemap';
|
||||
|
||||
/**
|
||||
* 방송 이벤트명 (클라이언트는 `.` 접두로 수신)
|
||||
*/
|
||||
public const EVENT = 'sitemap.progress.updated';
|
||||
|
||||
/**
|
||||
* 진행상황 캐시 TTL (초) — 잡이 하드 kill 로 죽어도 이 시간 후 자동 만료(무한 running 방지)
|
||||
*/
|
||||
private const TTL = 3600;
|
||||
|
||||
/**
|
||||
* URL 누적 방송 간격 — 같은 단계에서 urls 만 증가할 때 이 간격마다만 방송(방송 폭주 방지)
|
||||
*/
|
||||
private const BROADCAST_URL_INTERVAL = 5000;
|
||||
|
||||
/**
|
||||
* 마지막으로 방송한 시점의 누적 URL 수
|
||||
*/
|
||||
private int $lastBroadcastUrls = 0;
|
||||
|
||||
public function __construct(private CacheInterface $cache) {}
|
||||
|
||||
/**
|
||||
* 재생성 예약을 기록합니다(큐 대기 중에도 UI 가 'queued' 를 표시).
|
||||
*
|
||||
* @param string $mode 재생성 모드(full|auto|incremental)
|
||||
*/
|
||||
public function start(string $mode): void
|
||||
{
|
||||
$this->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<string, mixed> $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<string, mixed>|null 진행상황 또는 아직 실행 이력이 없으면 null
|
||||
*/
|
||||
public function get(): ?array
|
||||
{
|
||||
$state = $this->cache->get(self::KEY);
|
||||
|
||||
return is_array($state) ? $state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 진행상황을 반환하되, 없으면 기본 골격을 돌려줍니다(부분 갱신 병합용).
|
||||
*
|
||||
* @return array<string, mixed> 진행상황 골격
|
||||
*/
|
||||
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<string, mixed> $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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
-6
@@ -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
|
||||
<!-- @generated:start: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`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
|
||||
- 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
|
||||
- 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
|
||||
- 환경설정 > SEO 화면의 Sitemap 생성 진행 상황 표시(대기·생성 중·파일 작성 중·완료·실패 단계, 현재 처리 항목, 누적 URL 수, 실시간/주기 확인 안내) 일본어 번역 추가 — 진행 상황 화면이 일본어 로케일에서도 올바르게 표시됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -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": "セキュリティ 設定",
|
||||
|
||||
@@ -1575,7 +1575,9 @@
|
||||
"tokenValidation": "トークン検証",
|
||||
"user": "ユーザー",
|
||||
"users": "ユーザーリスト",
|
||||
"dashboard_recent_notifications": "ダッシュボード最近の通知"
|
||||
"dashboard_recent_notifications": "ダッシュボード最近の通知",
|
||||
"sitemap_status": "サイトマップ生成ステータス",
|
||||
"sitemap_progress_ws": "サイトマップ生成進行中(リアルタイム)"
|
||||
},
|
||||
"array_group": {
|
||||
"chart_labels": "項目ラベル",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+140
@@ -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');
|
||||
});
|
||||
});
|
||||
+1
@@ -29,6 +29,7 @@ const TAB_SCOPED: Record<string, string[]> = {
|
||||
identityMessages: ['identity'],
|
||||
adminIdentityPolicies: ['identity'],
|
||||
policies: ['identity'],
|
||||
sitemap_status: ['seo'],
|
||||
};
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "보안 설정",
|
||||
|
||||
@@ -1646,6 +1646,8 @@
|
||||
"schedule_history": "스케줄 이력",
|
||||
"schedules": "스케줄 목록",
|
||||
"settings": "환경설정",
|
||||
"sitemap_status": "Sitemap 생성 상태",
|
||||
"sitemap_progress_ws": "Sitemap 생성 진행 (실시간)",
|
||||
"systemInfo": "시스템 정보",
|
||||
"templateDetail": "템플릿 상세",
|
||||
"templateInstallPreview": "템플릿 설치 미리보기",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+107
-48
@@ -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')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ class SitemapManagerModeTest extends TestCase
|
||||
Mockery::spy(ConfigRepositoryInterface::class),
|
||||
$this->storage,
|
||||
$this->repository,
|
||||
$this->app->make(\App\Seo\SitemapProgress::class),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 키 길이 회피).
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Events\GenericBroadcastEvent;
|
||||
use App\Seo\SitemapProgress;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SitemapProgress 진행상황 스토어 테스트
|
||||
*
|
||||
* 상태 전이(queued→running→writing→completed|failed), 캐시 기록, 방송(Reverb ON),
|
||||
* 방송 스로틀(N URL 간격), Reverb OFF 시 캐시 폴백을 검증합니다.
|
||||
*/
|
||||
class SitemapProgressTest extends TestCase
|
||||
{
|
||||
private SitemapProgress $progress;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user