o2o-site-AEO/solution/frontend/src/features/onboarding/Step5Generating.tsx
Mina Choi b4085a0e0f [fix] solution: 온보딩 생성·크롤링 진단·장애 알림 묶음
운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것,
크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버):
사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이
429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일
이유가 없었다.

- solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은
  dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡
  진행(useGenerationJob) 그대로
- solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속.
  db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN.
  worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를
  Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가.
  collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다.
- postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션

검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인.
2026-09-16 16:25:02 +09:00

124 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {Check, Loader2} from 'lucide-react';
import {JobStatus} from '@o2o/shared';
import {Button} from '@/components/ui/button';
import {Progress} from '@/components/ui/progress';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {useGenerationJob} from './useGenerationJob';
import {GENERATION_LABELS, SKIP_REASONS} from './generationLabels';
/*
* ★ 겉모습만 옛 화면 스타일이고 데이터는 실제 잡 진행 상태다. `useGenerationJob`
* (새로고침해도 jobId 로 이어서 봄, 실패 시 복구 버튼)을 그대로 쓰고, 카드 모양·
* 진행률 바·번호 동그라미 목록만 그 스타일로 그린다. 단계 문구는 실제로 이 COPY
* 잡이 하는 일(prepare/generate/save/faq_fill)만 적는다 — 자세한 배경은 DEVLOG.md.
*/
export function Step5Generating() {
const storeName = useBuilderStore((s) => s.storeName);
const placeId = useBuilderStore((s) => s.placeId);
const {job, error, checkAgain, openEditor, goBack} = useGenerationJob(placeId);
const failed = job?.status === JobStatus.DEAD;
const waiting = job?.status === JobStatus.PENDING;
const steps = job?.progress?.steps ?? [];
const total = steps.length;
const doneCount = steps.filter((s) => s.status === 'done' || s.status === 'skipped').length;
const progress = total ? Math.min(100, Math.round((doneCount / total) * 100)) : 0;
const runningIndex = steps.findIndex((s) => s.status === 'running');
const currentLabel = runningIndex >= 0
? GENERATION_LABELS[steps[runningIndex].id] ?? '콘텐츠 처리'
: job?.status === JobStatus.DONE ? '완료'
: '준비 중';
const title = error ? '진행 상태 확인이 필요합니다'
: failed ? '콘텐츠 생성을 완료하지 못했습니다'
: waiting ? (job.attempts ? '생성을 다시 시도할 예정입니다' : '생성 순서를 기다리고 있습니다')
: job?.status === JobStatus.DONE ? '콘텐츠 생성을 마쳤습니다'
: '웹사이트를 생성하고 있습니다';
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<div className="mx-auto flex w-full max-w-md flex-col items-center text-center">
<div className="mb-6 flex size-16 items-center justify-center rounded-2xl border border-border bg-card">
{!error && !failed
? <Loader2 className="size-8 animate-spin text-primary" />
: <span className="text-2xl" aria-hidden="true"></span>}
</div>
<h1 className="mb-2 text-2xl font-bold tracking-tight sm:text-3xl" aria-live="polite">
{title}
</h1>
<p className="mb-8 max-w-md text-xs text-muted-foreground sm:text-sm">
{error || (
<>
<strong className="font-semibold text-foreground">{storeName}</strong>
.
</>
)}
</p>
{total > 0 && (
<div className="w-full space-y-4 rounded-2xl border border-border bg-card p-6 text-left">
<div className="flex items-center justify-between text-xs">
<span className="font-bold">{currentLabel}</span>
<span className="font-mono font-bold">{progress}%</span>
</div>
<Progress value={progress} aria-label="사이트 생성 진행률" />
<ol className="space-y-2.5 pt-2">
{steps.map((step, index) => {
const state = step.status === 'running' && failed ? 'failed' : step.status;
const isDone = state === 'done' || state === 'skipped';
const isCurrent = state === 'running';
return (
<li
key={step.id}
className={cn(
'flex items-center gap-3 text-xs transition-colors',
isDone || isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
<span
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
isDone || isCurrent
? 'bg-primary text-primary-foreground'
: 'border border-border bg-muted',
isCurrent && 'ring-2 ring-primary/20',
)}
>
{isDone ? <Check className="size-2.5" strokeWidth={3} /> : index + 1}
</span>
<span className="truncate">
{GENERATION_LABELS[step.id] ?? '콘텐츠 처리'}
{step.reason && (
<span className="ml-1 text-muted-foreground">
({SKIP_REASONS[step.reason] ?? '건너뜀'})
</span>
)}
</span>
</li>
);
})}
</ol>
</div>
)}
{!error && !failed && (
<p className="mt-6 text-xs text-muted-foreground">
.
</p>
)}
{(error || failed) && (
<div className="mt-6 flex flex-wrap justify-center gap-2">
{error && <Button onClick={checkAgain}> </Button>}
<Button variant="outline" onClick={goBack}> </Button>
<Button variant="outline" onClick={openEditor}> </Button>
</div>
)}
</div>
</div>
);
}