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, lastUpdatedAt: $meta['generated_at'] ?? null); } /** * 재생성 실패를 기록합니다(무한 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`/`last_updated_at`/`realtime_enabled` 를 담습니다(대시보드 방송과 동일 규약). * websocket 데이터소스가 `target_source` 로 대상 소스의 저장값을 통째 교체하므로, 봉투 * 형태여야 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지됩니다(D27). * * @param array $state 진행상황 상태 * @param bool $broadcast 방송 여부(스로틀 판정 결과) * @param string|null $lastUpdatedAt 이 방송에 실을 마지막 생성 시각(완료 시 새 시각, 그 외 직전 값) */ private function persist(array $state, bool $broadcast, ?string $lastUpdatedAt = null): void { $this->cache->put(self::KEY, $state, self::TTL); if (! $broadcast) { return; } // 미지정(진행 중 단계)이면 직전 완료 시각(settings)을 싣는다. target_source 가 대상 소스 // data 를 통째 교체하므로 매 방송마다 형제 필드를 온전히 실어야 "마지막 생성"이 유지된다. if ($lastUpdatedAt === null) { $stored = (string) g7_core_settings('seo.sitemap_last_updated_at', ''); $lastUpdatedAt = $stored !== '' ? $stored : null; } // 방송 payload 는 상태 API 응답 봉투와 동형이어야 한다(대시보드 방송과 동일 규약). // 최상위 data 봉투 안에 progress/last_updated_at/realtime_enabled 를 담아야 // target_source 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지된다. HookManager::broadcast(self::CHANNEL, self::EVENT, [ 'data' => [ 'last_updated_at' => $lastUpdatedAt, 'progress' => $state, 'realtime_enabled' => true, ], ]); } }