운영 번들 자동 로그인 자격증명 유출, 온보딩 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 알림 실채널 수신 확인.
124 lines
5.7 KiB
TypeScript
124 lines
5.7 KiB
TypeScript
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>
|
||
);
|
||
}
|