`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
199 lines
7.3 KiB
TypeScript
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,
|
|
};
|
|
}
|