o2o-site-AEO/solution/frontend/src/features/publish/usePublishSite.ts
Mina Choi c85c577349 이름: solution/front → solution/frontend
`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로
베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:27:16 +09:00

199 lines
7.3 KiB
TypeScript

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<string, string> = {
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<PublishState>(IDLE);
const running = useRef<AbortController | null>(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,
};
}