Compare commits

...

2 Commits

Author SHA1 Message Date
Mina Choi 68bb1d62cd dev(test): /test prefill 을 뷰성형외과 fixture 로 고정
- pickRandomIndex → pickIndex(PINNED_LABEL) 로 변경, 항상 뷰성형외과 선택.
- "다른 병원 랜덤" 버튼·seed state 제거 (의미 없어짐).
- CLINICS 의 나머지 9개 fixture 는 mockUrls.ts 에 그대로 보존.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:41:32 +09:00
Mina Choi da07bdc8c8 feat(report): ?run_id= 쿼리로 로딩페이지 resume + ky 타임아웃 해제
- AnalysisLoadingPage 가 /report/loading?run_id=xxx 쿼리도 path param 과 동일하게
  resume 트리거로 인식. createClinic·startAnalysis 재호출 없이 폴링만 재개.
- startAnalysis 성공 후 replaceState 를 path 가 아닌 ?run_id= 쿼리로 갱신해
  새로고침·공유·뒤로가기 모두 동일한 resume 흐름을 탄다.
- ky 의 60s 글로벌 timeout 을 끔. createClinic 처럼 백엔드가 외부 크롤·LLM 을
  동기로 호출하는 엔드포인트는 60s 안에 끝난다는 보장이 없어 클라이언트에서
  timeout 으로 끊으면 안 됨 (폴링은 어차피 짧음).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:41:32 +09:00
3 changed files with 27 additions and 23 deletions

View File

@ -5,19 +5,21 @@
* - "다른 병원 랜덤" * - "다른 병원 랜덤"
* - DevOnly localhost * - DevOnly localhost
*/ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import MultiChannelInput, { type AnalyzePayload } from '@/features/channels/components/MultiChannelInput' import MultiChannelInput, { type AnalyzePayload } from '@/features/channels/components/MultiChannelInput'
import { CLINICS } from '../fixtures/mockUrls' import { CLINICS } from '../fixtures/mockUrls'
function pickRandomIndex(): number { // 현재는 뷰성형외과만 prefill 한다. CLINICS 의 다른 fixture 는 삭제하지 않고 보존.
return Math.floor(Math.random() * CLINICS.length) const PINNED_LABEL = '뷰성형외과'
function pickIndex(): number {
const i = CLINICS.findIndex((c) => c.label === PINNED_LABEL)
return i >= 0 ? i : 0
} }
export default function TestPrefillPage() { export default function TestPrefillPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [seed, setSeed] = useState(0) const index = pickIndex()
const index = useMemo(() => pickRandomIndex(), [seed])
const clinic = CLINICS[index] const clinic = CLINICS[index]
const handleAnalyze = (payload: AnalyzePayload) => { const handleAnalyze = (payload: AnalyzePayload) => {
@ -45,18 +47,11 @@ export default function TestPrefillPage() {
{' '}<span className="text-slate-400">({index + 1} / {CLINICS.length})</span> {' '}<span className="text-slate-400">({index + 1} / {CLINICS.length})</span>
</p> </p>
</div> </div>
<button {/* PINNED_LABEL .
type="button" pickIndex() randomize . */}
onClick={() => setSeed((s) => s + 1)}
className="px-4 py-2 rounded-lg bg-white border border-slate-200 text-sm font-medium text-slate-700 hover:bg-slate-50 shadow-sm"
>
</button>
</div> </div>
{/* key={seed} 로 강제 remount — initialUrls는 mount 시점 1회만 반영되기 때문 */}
<MultiChannelInput <MultiChannelInput
key={seed}
variant="hero" variant="hero"
initialUrls={clinic.urls} initialUrls={clinic.urls}
onAnalyze={handleAnalyze} onAnalyze={handleAnalyze}

View File

@ -82,6 +82,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
const location = useLocation(); const location = useLocation();
const { reportId: urlReportId } = useParams<{ reportId?: string }>(); const { reportId: urlReportId } = useParams<{ reportId?: string }>();
// ?run_id= 쿼리로 진입하면 createClinic/startAnalysis 건너뛰고 폴링부터 시작.
// path 파라미터(/report/loading/:reportId) 와 동일 의미로 취급.
const queryRunId = new URLSearchParams(location.search).get('run_id');
const resumeRunId = urlReportId || queryRunId || undefined;
const locState = (location.state as { url?: string; manualChannels?: ManualChannels }) ?? {}; const locState = (location.state as { url?: string; manualChannels?: ManualChannels }) ?? {};
const url = locState.url; const url = locState.url;
const manualChannels = locState.manualChannels; const manualChannels = locState.manualChannels;
@ -137,7 +142,9 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
reportId = runId; // 백엔드: analysis_run_id 가 report 조회 키와 동일 reportId = runId; // 백엔드: analysis_run_id 가 report 조회 키와 동일
saveSession({ reportId, clinicId, runId, url: startUrl }); saveSession({ reportId, clinicId, runId, url: startUrl });
window.history.replaceState(null, '', `/report/loading/${reportId}`); // 새로고침·공유 시 createClinic/startAnalysis 재호출 없이 폴링만 재개되도록
// run_id 를 쿼리 파라미터로 URL 에 박아둔다.
window.history.replaceState(null, '', `/report/loading?run_id=${reportId}`);
} }
if (!runId) throw new Error('runId 없음'); if (!runId) throw new Error('runId 없음');
@ -203,17 +210,17 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
if (hasStarted.current) return; if (hasStarted.current) return;
hasStarted.current = true; hasStarted.current = true;
// 1. URL 파라미터 resume (예: /report/loading/abc-123) // 1. URL 파라미터/쿼리 resume (예: /report/loading/abc-123 또는 /report/loading?run_id=abc-123)
if (urlReportId) { if (resumeRunId) {
setPhase('resuming'); setPhase('resuming');
const session = loadSession(); const session = loadSession();
const runId = session.runId || urlReportId; const runId = session.runId || resumeRunId;
getAnalysisStatus(runId) getAnalysisStatus(runId)
.then((res) => { .then((res) => {
if (res.status !== 200) throw new Error('status fetch failed'); if (res.status !== 200) throw new Error('status fetch failed');
const status = res.data; const status = res.data;
if (status.status === AnalysisStatus.completed) { if (status.status === AnalysisStatus.completed) {
navigate(`/report/${urlReportId}`, { replace: true }); navigate(`/report/${resumeRunId}`, { replace: true });
return; return;
} }
if (status.status === AnalysisStatus.failed) { if (status.status === AnalysisStatus.failed) {
@ -222,13 +229,13 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
return; return;
} }
saveSession({ saveSession({
reportId: urlReportId, reportId: resumeRunId,
clinicId: session.clinicId || undefined, clinicId: session.clinicId || undefined,
runId, runId,
url: session.url || undefined, url: session.url || undefined,
}); });
runPipeline(undefined, { runPipeline(undefined, {
reportId: urlReportId, reportId: resumeRunId,
clinicId: session.clinicId || undefined, clinicId: session.clinicId || undefined,
runId, runId,
}); });
@ -274,7 +281,7 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
// 4. 신규 분석 시작 // 4. 신규 분석 시작
runPipeline(url); runPipeline(url);
}, [url, urlReportId, navigate, runPipeline]); }, [url, resumeRunId, navigate, runPipeline]);
return { return {
phase, phase,

View File

@ -14,7 +14,9 @@ import ky, { type KyInstance } from 'ky'
const API_BASE_URL = (__VITE_API_BASE_URL__ ?? '').replace(/\/$/, '') const API_BASE_URL = (__VITE_API_BASE_URL__ ?? '').replace(/\/$/, '')
export const kyInstance: KyInstance = ky.create({ export const kyInstance: KyInstance = ky.create({
timeout: 60_000, // createClinic·startAnalysis 처럼 백엔드가 외부 크롤·LLM 을 호출하는 동기 엔드포인트는
// 60s 안에 끝난다는 보장이 없어 클라이언트 타임아웃은 끈다. 폴링 호출은 어차피 짧다.
timeout: false,
retry: 1, retry: 1,
// orval generated 타입이 4xx/5xx 응답도 data로 받아오므로 throw 비활성 // orval generated 타입이 4xx/5xx 응답도 data로 받아오므로 throw 비활성
throwHttpErrors: false, throwHttpErrors: false,