import {useCallback, useEffect, useRef, useState} from 'react'; import {useQueryClient} from '@tanstack/react-query'; import { getAccessToken, getGetPlaceQueryKey, getGetSiteQueryKey, getListVersionsQueryKey, pollJob, startBuild, useGetSite, } from '@/api'; import type {SiteData, SiteVersionData} from '@/api'; import {describeError} from '@/lib/errorMessages'; import {notify, notifyApiError} from '@/lib/notify'; /** * 발행 = 빌드다. * * 백엔드에 "발행" 엔드포인트가 따로 없는 것이 실수가 아니다 — 발행 검수 게이트는 * 빌드 잡 안에 있고(services/build_service → publish_gate), 게이트를 우회하는 경로가 * 생기지 않게 하나로 묶여 있다. 그래서 프론트도 `POST /site/build {publish:true}` 하나만 부른다. * * POST /v1/place/{id}/site/build → job_id * GET /v1/job/{job_id} → DONE 이면 job.result 에 판정이 들어 있다 * * ★ 잡이 DONE 이어도 발행됐다는 뜻이 아니다. 게이트가 막으면 잡은 정상 종료하고 * result.gate.passed 가 false 로 온다 — 그걸 읽지 않으면 거부를 성공으로 보고한다. */ /** 빌드 잡이 남기는 결과(services/build_service.run_build 의 반환값). */ export interface BuildJobResult { place_id?: string; site_id?: string; version?: number; site_version_id?: string; build_status?: 'BUILT' | 'FAILED'; /** 빌드 자체가 터진 경우. 게이트 거부와 구분된다. */ error?: string; unique_content_count?: number; mismatches?: string[]; html_bytes?: number; /** publish=true 로 요청했고 게이트를 통과했을 때만 true. */ published?: boolean; gate?: BuildGateResult; } /** `publish_gate.GateResult.as_log()` — reason 은 PublishRejectReason 의 **이름**이다(코드값 아님). */ export interface BuildGateResult { passed: boolean; reason?: string | null; /** UNVERIFIED_FACT */ unverified?: {key: string; status: string}[]; count?: number; /** REQUIRED_FACT_MISSING */ missing?: string[]; labels?: string[]; /** NO_UNIQUE_CONTENT */ unique_content_count?: number; /** JSONLD_MISMATCH */ mismatches?: string[]; } export type PublishPhase = | 'idle' /** 잡을 넣고 폴링 중. */ | 'building' | 'published' /** 게이트가 막았다 — 고칠 곳이 gate 에 들어 있다. */ | 'rejected' /** 빌드가 터졌거나 상태를 못 읽었다. */ | 'failed'; export interface PublishState { phase: PublishPhase; result?: BuildJobResult; /** 서버가 준 사람이 읽을 실패 사유. phase 가 failed 일 때만 채워진다. */ error?: string; } const IDLE: PublishState = {phase: 'idle'}; /** 게이트 거부 사유(백엔드 enum 이름) → 사장님이 읽을 문구. */ export const GATE_REASON_LABEL: Record = { UNVERIFIED_FACT: '확인되지 않은 정보가 섞여 있습니다', NO_UNIQUE_CONTENT: '이 가게만의 문장이 한 건도 없습니다', JSONLD_MISMATCH: '구조화 데이터와 화면 값이 다릅니다', REQUIRED_FACT_MISSING: '업종 필수 항목이 비어 있습니다', }; export interface UsePublishSiteResult { /** ★ null 이면 데모 경로다 — 이 훅은 네트워크를 한 번도 타지 않는다. */ isLive: boolean; site?: SiteData; currentVersion?: SiteVersionData; /** 노출값이 바뀐 뒤 다시 빌드하지 않았다 — 이 사업장만 재빌드하면 된다. */ needsRebuild: boolean; state: PublishState; isPublishing: boolean; publish: () => void; reset: () => void; } export function usePublishSite(placeId: string | null): UsePublishSiteResult { const queryClient = useQueryClient(); const [state, setState] = useState(IDLE); const running = useRef(null); // 토큰이 없으면 사이트 상태도 못 읽는다(전 엔드포인트가 인증 필요) — 부르지 않는다. const isLive = Boolean(placeId) && Boolean(getAccessToken()); const id = placeId ?? ''; const siteQuery = useGetSite(id, {query: {enabled: isLive}}); useEffect(() => () => running.current?.abort(), []); const reset = useCallback(() => { running.current?.abort(); running.current = null; setState(IDLE); }, []); const publish = useCallback(() => { if (!isLive || running.current) return; const controller = new AbortController(); running.current = controller; setState({phase: 'building'}); void (async () => { let jobId: string; try { const started = await startBuild(id, {publish: true}, undefined, controller.signal); if (started.result?.success === false || !started.job_id) { // 검증 전 사업장(PLACE_NOT_VERIFIED)·이미 도는 빌드 등은 여기서 걸린다. notifyApiError({data: started}, '발행을 시작하지 못했습니다.'); setState({phase: 'failed', error: describeError(started.result?.desc)}); return; } jobId = started.job_id; } catch (error) { if (controller.signal.aborted) return; notifyApiError(error, '발행을 시작하지 못했습니다.'); setState({phase: 'failed'}); return; } const outcome = await pollJob(jobId, {signal: controller.signal}); if (outcome.kind === 'aborted') return; if (outcome.kind === 'dead') { notify.error('빌드가 실패했습니다.', outcome.job.last_error ?? undefined); setState({phase: 'failed', error: outcome.job.last_error ?? undefined}); return; } if (outcome.kind === 'unreachable') { notifyApiError(outcome.error, '빌드 상태를 확인하지 못했습니다.'); setState({phase: 'failed'}); return; } if (outcome.kind === 'timeout') { notify.warn('빌드가 예상보다 오래 걸립니다.', '빌드는 계속 진행됩니다.'); setState({phase: 'failed'}); return; } const result = (outcome.job.result ?? {}) as BuildJobResult; // ★ 순서가 중요하다. 게이트 거부는 잡이 정상 종료(DONE)하고 build_status 만 FAILED 다 — // build_status 부터 보면 게이트 거부가 "빌드 실패"로 뭉개진다. if (result.gate && !result.gate.passed) { setState({phase: 'rejected', result}); } else if (result.published) { notify.success('발행했습니다', '확인된 정보만 담긴 정적 페이지가 나갔습니다.'); setState({phase: 'published', result}); } else { setState({phase: 'failed', result, error: result.error}); notify.error('빌드하지 못했습니다.', result.error ?? undefined); } // 결과가 무엇이든 사이트 상태·버전 목록·사업장(status)은 다시 읽는다. void queryClient.invalidateQueries({queryKey: getGetSiteQueryKey(id)}); void queryClient.invalidateQueries({queryKey: getListVersionsQueryKey(id)}); void queryClient.invalidateQueries({queryKey: getGetPlaceQueryKey(id)}); })().finally(() => { if (running.current === controller) running.current = null; }); }, [isLive, id, queryClient]); return { isLive, site: siteQuery.data?.site ?? undefined, currentVersion: siteQuery.data?.current_version ?? undefined, needsRebuild: Boolean(siteQuery.data?.needs_rebuild), state, isPublishing: state.phase === 'building', publish, reset, }; }