From ab62dc9441688de6e3b93982508b92385fa0c30b Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Wed, 20 May 2026 17:08:51 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EB=B0=8F=20=ED=8F=B4=EB=A7=81=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/analyses.ts | 57 +++++++ app/(tabs)/(home)/scanning.tsx | 298 ++++++++++++++++++++++++++++++--- 2 files changed, 333 insertions(+), 22 deletions(-) create mode 100644 api/analyses.ts diff --git a/api/analyses.ts b/api/analyses.ts new file mode 100644 index 0000000..d88a861 --- /dev/null +++ b/api/analyses.ts @@ -0,0 +1,57 @@ +import { + authenticatedApiRequest, + type ApiRequestOptions, + type ClerkTokenGetter, +} from '@/api/api-client'; + +export type AnalysisStatus = 'queued' | 'succeeded' | 'failed'; +export type AnalysisVerdict = 'safe' | 'caution' | 'danger'; + +export type AnalysisReason = { + code: string; + stage: number; + weight: number; + message: string; +}; + +export type AnalysisResponse = { + analysisId: string; + status: AnalysisStatus; + originalUrl?: string; + finalUrl?: string; + verdict?: AnalysisVerdict; + score?: number; + summary?: string; + reasons?: AnalysisReason[]; + analyzedAt?: string; + elapsedMs?: number; + errorCode?: string; + errorStage?: number; + errorMessage?: string; +}; + +type AnalysisRequestOptions = Pick; + +export function requestAnalysis( + getToken: ClerkTokenGetter, + url: string, + options: AnalysisRequestOptions = {}, +) { + return authenticatedApiRequest(getToken, '/api/v1/analyses', { + ...options, + method: 'POST', + body: { url }, + }); +} + +export function fetchAnalysis( + getToken: ClerkTokenGetter, + analysisId: string, + options: AnalysisRequestOptions = {}, +) { + return authenticatedApiRequest( + getToken, + `/api/v1/analyses/${encodeURIComponent(analysisId)}`, + options, + ); +} diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx index 25031ca..a258e41 100644 --- a/app/(tabs)/(home)/scanning.tsx +++ b/app/(tabs)/(home)/scanning.tsx @@ -1,29 +1,93 @@ +import { useAuth } from '@clerk/expo'; import LottieView from 'lottie-react-native'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { useEffect } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { fetchAnalysis, requestAnalysis, type AnalysisResponse, type AnalysisVerdict } from '@/api/analyses'; +import { ApiError } from '@/api/api-client'; import { Colors, Typography } from '@/constants/theme'; -// TODO: 백엔드 연동 시 POST /api/v1/analyses 호출 후 폴링으로 결과 확인 -// Request: { original_url } -// Response: { analysis_id, status, verdict, score, summary } -// verdict: 'safe' → scan-result(allowed), 'caution'/'danger' → 별도 결과 화면 -// API 명세: Draft of the specification.md > POST /analyses, GET /analyses/{analysisId} 참고 +const POLLING_INTERVAL_MS = 2_000; +const MAX_POLLING_MS = 30_000; export default function ScanningScreen() { - const { url } = useLocalSearchParams<{ url: string }>(); + const { getToken, isLoaded, isSignedIn } = useAuth(); + const { url: urlParam } = useLocalSearchParams<{ url?: string | string[] }>(); + const url = getUrlParam(urlParam); + const [errorMessage, setErrorMessage] = useState(''); + const [retryKey, setRetryKey] = useState(0); + const getTokenRef = useRef(getToken); useEffect(() => { - // TODO: 실제 백엔드 분석 요청으로 교체 - // verdict에 따라 화면 분기: - // safe → '/(tabs)/(home)/scan-result' - // caution → '/(tabs)/(home)/scan-result-caution' - // block → '/(tabs)/(home)/scan-result-block' - const timer = setTimeout(() => { - router.replace({ pathname: '/(tabs)/(home)/scan-result-block', params: { url } }); - }, 3000); - return () => clearTimeout(timer); - }, [url]); + getTokenRef.current = getToken; + }, [getToken]); + + useEffect(() => { + const abortController = new AbortController(); + let timeoutId: ReturnType | null = null; + let timedOut = false; + + if (!isLoaded) { + return () => abortController.abort(); + } + + if (!isSignedIn) { + setErrorMessage('로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'); + return () => abortController.abort(); + } + + if (!url) { + setErrorMessage('검사할 URL을 찾을 수 없습니다. 링크를 다시 입력해주세요.'); + return () => abortController.abort(); + } + + setErrorMessage(''); + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + timedOut = true; + abortController.abort(); + reject(new Error('TIMEOUT')); + }, MAX_POLLING_MS); + }); + + Promise.race([ + runAnalysisPolling({ + getToken: () => getTokenRef.current(), + url, + signal: abortController.signal, + }), + timeoutPromise, + ]) + .catch((error) => { + if (timedOut) { + setErrorMessage(getAnalysisErrorMessage(new Error('TIMEOUT'))); + return; + } + + if (isAbortError(error)) { + return; + } + + setErrorMessage(getAnalysisErrorMessage(error)); + }) + .finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + abortController.abort(); + }; + }, [isLoaded, isSignedIn, retryKey, url]); + + const hasError = errorMessage.length > 0; return ( <> @@ -43,8 +107,8 @@ export default function ScanningScreen() { @@ -55,8 +119,12 @@ export default function ScanningScreen() { {/* 텍스트 */} - 보안 검사 중입니다 - 약 5–10초 정도 소요돼요 + + {hasError ? '검사를 완료하지 못했어요' : '보안 검사 중입니다'} + + + {hasError ? errorMessage : '약 5-10초 정도 소요돼요'} + {/* 검사 대상 카드 */} @@ -65,11 +133,163 @@ export default function ScanningScreen() { {url} + + {hasError && ( + + setRetryKey((key) => key + 1)} + activeOpacity={0.8} + > + 다시 검사 + + router.back()} + activeOpacity={0.8} + > + 돌아가기 + + + )} ); } +async function runAnalysisPolling({ + getToken, + url, + signal, +}: { + getToken: () => Promise; + url: string; + signal: AbortSignal; +}) { + const deadline = Date.now() + MAX_POLLING_MS; + let analysis = await requestAnalysis(getToken, url, { signal }); + + while (!signal.aborted) { + const handled = handleAnalysisResult(analysis, url, signal); + + if (handled) { + return; + } + + const remainingMs = deadline - Date.now(); + + if (remainingMs <= 0) { + throw new Error('TIMEOUT'); + } + + await wait(Math.min(POLLING_INTERVAL_MS, remainingMs), signal); + analysis = await fetchAnalysis(getToken, analysis.analysisId, { signal }); + } +} + +function handleAnalysisResult(analysis: AnalysisResponse, fallbackUrl: string, signal: AbortSignal) { + if (analysis.status === 'queued') { + return false; + } + + if (analysis.status === 'failed') { + throw new Error(analysis.errorMessage || 'ANALYSIS_FAILED'); + } + + if (!analysis.verdict) { + throw new Error('MISSING_VERDICT'); + } + + if (!signal.aborted) { + router.replace({ + pathname: getResultPath(analysis.verdict), + params: { + url: analysis.originalUrl ?? fallbackUrl, + analysisId: analysis.analysisId, + verdict: analysis.verdict, + }, + }); + } + + return true; +} + +function getResultPath(verdict: AnalysisVerdict) { + switch (verdict) { + case 'safe': + return '/(tabs)/(home)/scan-result'; + case 'caution': + return '/(tabs)/(home)/scan-result-caution'; + case 'danger': + return '/(tabs)/(home)/scan-result-block'; + } +} + +function wait(ms: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(createAbortError()); + return; + } + + const handleAbort = () => { + clearTimeout(timer); + reject(createAbortError()); + }; + + const timer = setTimeout(() => { + signal.removeEventListener('abort', handleAbort); + resolve(); + }, ms); + + signal.addEventListener('abort', handleAbort, { once: true }); + }); +} + +function createAbortError() { + const error = new Error('Analysis polling aborted'); + error.name = 'AbortError'; + return error; +} + +function isAbortError(error: unknown) { + return typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError'; +} + +function getAnalysisErrorMessage(error: unknown) { + if (error instanceof ApiError) { + if (error.status === 401 || error.status === 403) { + return '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; + } + + return error.message || '링크 검사 요청에 실패했습니다. 잠시 후 다시 시도해주세요.'; + } + + if (error instanceof Error) { + if (error.message === 'Missing Clerk session token') { + return '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; + } + + if (error.message === 'TIMEOUT') { + return '분석 결과를 기다리는 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.'; + } + + if (error.message === 'MISSING_VERDICT') { + return '분석 결과를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'; + } + + if (error.message && error.message !== 'ANALYSIS_FAILED') { + return error.message; + } + } + + return '링크 검사에 실패했습니다. 잠시 후 다시 시도해주세요.'; +} + +function getUrlParam(value: string | string[] | undefined) { + return typeof value === 'string' ? value : ''; +} + const styles = StyleSheet.create({ container: { flex: 1, @@ -120,6 +340,7 @@ const styles = StyleSheet.create({ borderRadius: 16, padding: 16, gap: 6, + marginBottom: 24, }, cardLabel: { ...Typography.caption, @@ -130,4 +351,37 @@ const styles = StyleSheet.create({ fontWeight: '700', color: Colors.brand.text, }, + errorText: { + color: Colors.brand.textWarning, + }, + buttonArea: { + width: '100%', + gap: 12, + }, + primaryButton: { + width: '100%', + height: 56, + borderRadius: 28, + backgroundColor: Colors.brand.primary, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { + ...Typography.section, + color: Colors.brand.onPrimary, + }, + secondaryButton: { + width: '100%', + height: 56, + borderRadius: 28, + backgroundColor: Colors.brand.surface, + borderWidth: 1.5, + borderColor: Colors.brand.line, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { + ...Typography.section, + color: Colors.brand.text, + }, }); From 59ee1d8a22cfac47c30761a89d3af8bcc3cd1923 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Wed, 20 May 2026 18:15:08 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EA=B2=B0=EA=B3=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/scan-result-block.tsx | 35 +++++- app/(tabs)/(home)/scan-result-caution.tsx | 59 +++++++--- app/(tabs)/(home)/scan-result.tsx | 59 +++++++--- hooks/use-analysis-result.ts | 127 ++++++++++++++++++++++ utils/analysis-result-display.ts | 43 ++++++++ 5 files changed, 287 insertions(+), 36 deletions(-) create mode 100644 hooks/use-analysis-result.ts create mode 100644 utils/analysis-result-display.ts diff --git a/app/(tabs)/(home)/scan-result-block.tsx b/app/(tabs)/(home)/scan-result-block.tsx index a018da7..5a1c1a8 100644 --- a/app/(tabs)/(home)/scan-result-block.tsx +++ b/app/(tabs)/(home)/scan-result-block.tsx @@ -4,11 +4,23 @@ import { ResultStatusIcon } from '@/components/ui/result-status-icon'; import { ScanResultReason } from '@/components/ui/scan-result-reason'; import { getMockScanResultReason } from '@/constants/scan-result-reasons'; import { Colors, Typography } from '@/constants/theme'; +import { useAnalysisResult } from '@/hooks/use-analysis-result'; +import { + getAnalysisDisplayUrl, + getAnalysisReasonText, + getRouteParam, +} from '@/utils/analysis-result-display'; export default function ScanResultBlockScreen() { - const { url } = useLocalSearchParams<{ url: string }>(); - // TODO: 테스트용 mock 판정 이유입니다. 백엔드 reason 응답 연동 시 제거합니다. - const reason = getMockScanResultReason('danger'); + const { + analysisId: analysisIdParam, + url: urlParam, + } = useLocalSearchParams<{ analysisId?: string | string[]; url?: string | string[] }>(); + const analysisId = getRouteParam(analysisIdParam); + const url = getRouteParam(urlParam); + const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId); + const displayUrl = getAnalysisDisplayUrl(analysis, url); + const reason = getAnalysisReasonText(analysis, getMockScanResultReason('danger')); return ( <> @@ -36,13 +48,16 @@ export default function ScanResultBlockScreen() { {/* 결과 텍스트 */} 차단된 위험 링크입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} + {errorMessage ? {errorMessage} : null} + {/* 검사 대상 카드 */} 검사 대상 - {url} + {displayUrl} @@ -88,6 +103,18 @@ const styles = StyleSheet.create({ reasonCard: { marginBottom: 24, }, + statusText: { + ...Typography.caption, + color: Colors.brand.textSecondary, + textAlign: 'center', + marginBottom: 10, + }, + errorText: { + ...Typography.caption, + color: Colors.brand.textWarning, + textAlign: 'center', + marginBottom: 10, + }, card: { width: '100%', backgroundColor: Colors.brand.surface, diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx index 0631053..cb144b1 100644 --- a/app/(tabs)/(home)/scan-result-caution.tsx +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -7,29 +7,41 @@ import { getMockScanResultReason } from '@/constants/scan-result-reasons'; import { Colors, Typography } from '@/constants/theme'; import { useSavedLinks } from '@/context/saved-links-context'; import { LinkSaveModal } from '@/components/ui/link-save-modal'; +import { useAnalysisResult } from '@/hooks/use-analysis-result'; +import { + getAnalysisDisplayUrl, + getAnalysisFinalUrl, + getAnalysisReasonText, + getRouteParam, + getSiteName, +} from '@/utils/analysis-result-display'; export default function ScanResultCautionScreen() { - const { url } = useLocalSearchParams<{ url: string }>(); + const { + analysisId: analysisIdParam, + url: urlParam, + } = useLocalSearchParams<{ analysisId?: string | string[]; url?: string | string[] }>(); const { addLink } = useSavedLinks(); - // TODO: 테스트용 mock 판정 이유입니다. 백엔드 reason 응답 연동 시 제거합니다. - const reason = getMockScanResultReason('caution'); + const analysisId = getRouteParam(analysisIdParam); + const url = getRouteParam(urlParam); + const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId); + const displayUrl = getAnalysisDisplayUrl(analysis, url); + const finalUrl = getAnalysisFinalUrl(analysis, displayUrl); + const reason = getAnalysisReasonText(analysis, getMockScanResultReason('caution')); const [saveModalVisible, setSaveModalVisible] = useState(false); const handleSave = (title: string) => { - const resolvedUrl = url ?? ''; // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 addLink({ id: Date.now(), - analysisId: `mock-${Date.now()}`, + analysisId: analysis?.analysisId ?? analysisId ?? `mock-${Date.now()}`, categoryId: null, - originalUrl: resolvedUrl, - finalUrl: resolvedUrl || null, + originalUrl: displayUrl, + finalUrl: finalUrl || null, title, - description: '저장된 링크입니다.', - siteName: (() => { - try { return new URL(resolvedUrl).hostname; } catch { return '알 수 없음'; } - })(), - verdict: 'caution', + description: analysis?.summary ?? '저장된 링크입니다.', + siteName: getSiteName(finalUrl || displayUrl), + verdict: analysis?.verdict ?? 'caution', isBookmarked: false, createdAt: new Date().toISOString(), }); @@ -38,9 +50,9 @@ export default function ScanResultCautionScreen() { }; const handleOpenUrl = async () => { - if (url) { + if (finalUrl) { try { - await Linking.openURL(url); + await Linking.openURL(finalUrl); } catch { // URL을 열 수 없는 경우 무시 } @@ -73,13 +85,16 @@ export default function ScanResultCautionScreen() { {/* 결과 텍스트 */} 주의가 필요한 링크입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} + {errorMessage ? {errorMessage} : null} + {/* 검사 대상 카드 */} 검사 대상 - {url} + {displayUrl} @@ -97,7 +112,7 @@ export default function ScanResultCautionScreen() { setSaveModalVisible(false)} onSave={handleSave} /> @@ -132,6 +147,18 @@ const styles = StyleSheet.create({ reasonCard: { marginBottom: 24, }, + statusText: { + ...Typography.caption, + color: Colors.brand.textSecondary, + textAlign: 'center', + marginBottom: 10, + }, + errorText: { + ...Typography.caption, + color: Colors.brand.textWarning, + textAlign: 'center', + marginBottom: 10, + }, card: { width: '100%', backgroundColor: Colors.brand.surface, diff --git a/app/(tabs)/(home)/scan-result.tsx b/app/(tabs)/(home)/scan-result.tsx index 42cf739..c07bb47 100644 --- a/app/(tabs)/(home)/scan-result.tsx +++ b/app/(tabs)/(home)/scan-result.tsx @@ -7,6 +7,14 @@ import { getMockScanResultReason } from '@/constants/scan-result-reasons'; import { Colors, Typography } from '@/constants/theme'; import { useSavedLinks } from '@/context/saved-links-context'; import { LinkSaveModal } from '@/components/ui/link-save-modal'; +import { useAnalysisResult } from '@/hooks/use-analysis-result'; +import { + getAnalysisDisplayUrl, + getAnalysisFinalUrl, + getAnalysisReasonText, + getRouteParam, + getSiteName, +} from '@/utils/analysis-result-display'; // TODO: 백엔드 연동 시 아래 흐름으로 교체 // 1. scanning.tsx에서 POST /api/v1/analyses → analysisId 수신 후 params로 전달 @@ -16,28 +24,32 @@ import { LinkSaveModal } from '@/components/ui/link-save-modal'; // API 명세: Draft of the specification.md > 4.1 링크 저장 참고 export default function ScanResultScreen() { - const { url } = useLocalSearchParams<{ url: string }>(); + const { + analysisId: analysisIdParam, + url: urlParam, + } = useLocalSearchParams<{ analysisId?: string | string[]; url?: string | string[] }>(); const { addLink } = useSavedLinks(); - // TODO: 테스트용 mock 판정 이유입니다. 백엔드 reason 응답 연동 시 제거합니다. - const reason = getMockScanResultReason('safe'); + const analysisId = getRouteParam(analysisIdParam); + const url = getRouteParam(urlParam); + const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId); + const displayUrl = getAnalysisDisplayUrl(analysis, url); + const finalUrl = getAnalysisFinalUrl(analysis, displayUrl); + const reason = getAnalysisReasonText(analysis, getMockScanResultReason('safe')); const [saveModalVisible, setSaveModalVisible] = useState(false); const handleSave = (title: string) => { - const resolvedUrl = url ?? ''; // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 // 현재는 URL 기반 mock 데이터로 즉시 추가 addLink({ id: Date.now(), - analysisId: `mock-${Date.now()}`, + analysisId: analysis?.analysisId ?? analysisId ?? `mock-${Date.now()}`, categoryId: null, - originalUrl: resolvedUrl, - finalUrl: resolvedUrl || null, + originalUrl: displayUrl, + finalUrl: finalUrl || null, title, - description: '저장된 링크입니다.', - siteName: (() => { - try { return new URL(resolvedUrl).hostname; } catch { return '알 수 없음'; } - })(), - verdict: 'safe', + description: analysis?.summary ?? '저장된 링크입니다.', + siteName: getSiteName(finalUrl || displayUrl), + verdict: analysis?.verdict ?? 'safe', isBookmarked: false, createdAt: new Date().toISOString(), }); @@ -46,9 +58,9 @@ export default function ScanResultScreen() { }; const handleOpenUrl = async () => { - if (url) { + if (finalUrl) { try { - await Linking.openURL(url); + await Linking.openURL(finalUrl); } catch { // URL을 열 수 없는 경우 무시 } @@ -81,13 +93,16 @@ export default function ScanResultScreen() { {/* 결과 텍스트 */} 안전한 웹사이트입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} + {errorMessage ? {errorMessage} : null} + {/* 검사 대상 카드 */} 검사 대상 - {url} + {displayUrl} @@ -105,7 +120,7 @@ export default function ScanResultScreen() { setSaveModalVisible(false)} onSave={handleSave} /> @@ -142,6 +157,18 @@ const styles = StyleSheet.create({ reasonCard: { marginBottom: 24, }, + statusText: { + ...Typography.caption, + color: Colors.brand.textSecondary, + textAlign: 'center', + marginBottom: 10, + }, + errorText: { + ...Typography.caption, + color: Colors.brand.textWarning, + textAlign: 'center', + marginBottom: 10, + }, // 검사 대상 카드 card: { width: '100%', diff --git a/hooks/use-analysis-result.ts b/hooks/use-analysis-result.ts new file mode 100644 index 0000000..8e3fd18 --- /dev/null +++ b/hooks/use-analysis-result.ts @@ -0,0 +1,127 @@ +import { useAuth } from '@clerk/expo'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { fetchAnalysis, type AnalysisResponse } from '@/api/analyses'; +import { ApiError } from '@/api/api-client'; + +const LOGIN_REQUIRED_MESSAGE = + '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; + +export function useAnalysisResult(analysisId?: string) { + const { getToken, isLoaded, isSignedIn } = useAuth(); + const [analysis, setAnalysis] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const getTokenRef = useRef(getToken); + + useEffect(() => { + getTokenRef.current = getToken; + }, [getToken]); + + const loadAnalysis = useCallback( + async (signal?: AbortSignal) => { + if (!analysisId) { + setAnalysis(null); + setIsLoading(false); + setErrorMessage(''); + return; + } + + if (!isLoaded) { + return; + } + + if (!isSignedIn) { + setAnalysis(null); + setIsLoading(false); + setErrorMessage(LOGIN_REQUIRED_MESSAGE); + return; + } + + setAnalysis(null); + setIsLoading(true); + setErrorMessage(''); + + try { + const nextAnalysis = await fetchAnalysis( + () => getTokenRef.current(), + analysisId, + { signal }, + ); + + if (signal?.aborted) { + return; + } + + setAnalysis(nextAnalysis); + } catch (error) { + if (signal?.aborted) { + return; + } + + if (isAbortError(error)) { + return; + } + + setErrorMessage(getAnalysisResultErrorMessage(error)); + } finally { + if (!signal?.aborted) { + setIsLoading(false); + } + } + }, + [analysisId, isLoaded, isSignedIn], + ); + + useEffect(() => { + if (!isLoaded) { + return; + } + + if (!analysisId) { + setAnalysis(null); + setIsLoading(false); + setErrorMessage(''); + return; + } + + const abortController = new AbortController(); + + void loadAnalysis(abortController.signal); + + return () => abortController.abort(); + }, [analysisId, isLoaded, loadAnalysis]); + + return { + analysis, + isLoading, + errorMessage, + refetch: loadAnalysis, + }; +} + +function getAnalysisResultErrorMessage(error: unknown) { + if (error instanceof ApiError) { + if (error.status === 401 || error.status === 403) { + return LOGIN_REQUIRED_MESSAGE; + } + + return error.message || '분석 결과를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.'; + } + + if (error instanceof Error) { + if (error.message === 'Missing Clerk session token') { + return LOGIN_REQUIRED_MESSAGE; + } + + if (error.message) { + return error.message; + } + } + + return '분석 결과를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.'; +} + +function isAbortError(error: unknown) { + return typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError'; +} diff --git a/utils/analysis-result-display.ts b/utils/analysis-result-display.ts new file mode 100644 index 0000000..7cc7fbd --- /dev/null +++ b/utils/analysis-result-display.ts @@ -0,0 +1,43 @@ +import type { AnalysisResponse } from '@/api/analyses'; + +export function getRouteParam(value: string | string[] | undefined) { + return typeof value === 'string' ? value : undefined; +} + +export function getAnalysisDisplayUrl(analysis: AnalysisResponse | null, fallbackUrl?: string) { + return analysis?.originalUrl ?? fallbackUrl ?? ''; +} + +export function getAnalysisFinalUrl(analysis: AnalysisResponse | null, fallbackUrl: string) { + return analysis?.finalUrl ?? fallbackUrl; +} + +export function getAnalysisReasonText( + analysis: AnalysisResponse | null, + fallbackReason: string, +) { + const summary = analysis?.summary?.trim(); + + if (summary) { + return summary; + } + + const reasonMessages = + analysis?.reasons + ?.map((reason) => reason.message.trim()) + .filter((message) => message.length > 0) ?? []; + + if (reasonMessages.length > 0) { + return reasonMessages.join('\n'); + } + + return fallbackReason; +} + +export function getSiteName(url: string) { + try { + return new URL(url).hostname; + } catch { + return '정보 없음'; + } +} From 697ec9e78e9a5d001dcdd3689e105900053bfb46 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Wed, 20 May 2026 22:53:06 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EA=B2=B0=EA=B3=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=8C=90=EC=A0=95=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/scan-result-block.tsx | 47 ++++++++++++++++++++++ app/(tabs)/(home)/scan-result-caution.tsx | 48 ++++++++++++++++++++++- app/(tabs)/(home)/scan-result.tsx | 48 ++++++++++++++++++++++- utils/analysis-result-display.ts | 13 +++++- 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/app/(tabs)/(home)/scan-result-block.tsx b/app/(tabs)/(home)/scan-result-block.tsx index 5a1c1a8..3fd1be5 100644 --- a/app/(tabs)/(home)/scan-result-block.tsx +++ b/app/(tabs)/(home)/scan-result-block.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; @@ -8,6 +9,7 @@ import { useAnalysisResult } from '@/hooks/use-analysis-result'; import { getAnalysisDisplayUrl, getAnalysisReasonText, + getAnalysisResultPath, getRouteParam, } from '@/utils/analysis-result-display'; @@ -21,6 +23,44 @@ export default function ScanResultBlockScreen() { const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId); const displayUrl = getAnalysisDisplayUrl(analysis, url); const reason = getAnalysisReasonText(analysis, getMockScanResultReason('danger')); + const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'danger'); + const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); + + useEffect(() => { + if (!analysis?.verdict || analysis.verdict === 'danger') { + return; + } + + router.replace({ + pathname: getAnalysisResultPath(analysis.verdict), + params: { + url: analysis.originalUrl ?? url ?? '', + analysisId: analysis.analysisId, + verdict: analysis.verdict, + }, + }); + }, [analysis, url]); + + if (isVerifyingAnalysis || shouldRedirectToVerdict) { + return ( + <> + + + 遺꾩꽍 寃곌낵瑜?遺덈윭?ㅻ뒗 以묒엯?덈떎. + + + ); + } return ( <> @@ -115,6 +155,13 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 10, }, + loadingContainer: { + flex: 1, + backgroundColor: Colors.brand.background, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 24, + }, card: { width: '100%', backgroundColor: Colors.brand.surface, diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx index cb144b1..5b189ae 100644 --- a/app/(tabs)/(home)/scan-result-caution.tsx +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; @@ -12,6 +12,7 @@ import { getAnalysisDisplayUrl, getAnalysisFinalUrl, getAnalysisReasonText, + getAnalysisResultPath, getRouteParam, getSiteName, } from '@/utils/analysis-result-display'; @@ -29,6 +30,23 @@ export default function ScanResultCautionScreen() { const finalUrl = getAnalysisFinalUrl(analysis, displayUrl); const reason = getAnalysisReasonText(analysis, getMockScanResultReason('caution')); const [saveModalVisible, setSaveModalVisible] = useState(false); + const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'caution'); + const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); + + useEffect(() => { + if (!analysis?.verdict || analysis.verdict === 'caution') { + return; + } + + router.replace({ + pathname: getAnalysisResultPath(analysis.verdict), + params: { + url: analysis.originalUrl ?? url ?? '', + analysisId: analysis.analysisId, + verdict: analysis.verdict, + }, + }); + }, [analysis, url]); const handleSave = (title: string) => { // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 @@ -59,6 +77,27 @@ export default function ScanResultCautionScreen() { } }; + if (isVerifyingAnalysis || shouldRedirectToVerdict) { + return ( + <> + + + 遺꾩꽍 寃곌낵瑜?遺덈윭?ㅻ뒗 以묒엯?덈떎. + + + ); + } + return ( <> { + if (!analysis?.verdict || analysis.verdict === 'safe') { + return; + } + + router.replace({ + pathname: getAnalysisResultPath(analysis.verdict), + params: { + url: analysis.originalUrl ?? url ?? '', + analysisId: analysis.analysisId, + verdict: analysis.verdict, + }, + }); + }, [analysis, url]); const handleSave = (title: string) => { // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 @@ -67,6 +85,27 @@ export default function ScanResultScreen() { } }; + if (isVerifyingAnalysis || shouldRedirectToVerdict) { + return ( + <> + + + 遺꾩꽍 寃곌낵瑜?遺덈윭?ㅻ뒗 以묒엯?덈떎. + + + ); + } + return ( <> Date: Wed, 20 May 2026 23:01:39 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EB=B9=88=20URL=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/scan-result-caution.tsx | 15 ++++++++++++++- app/(tabs)/(home)/scan-result.tsx | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx index 5b189ae..2cf9107 100644 --- a/app/(tabs)/(home)/scan-result-caution.tsx +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -32,6 +32,7 @@ export default function ScanResultCautionScreen() { const [saveModalVisible, setSaveModalVisible] = useState(false); const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'caution'); const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); + const canSave = displayUrl.trim().length > 0 && !isLoading && !shouldRedirectToVerdict; useEffect(() => { if (!analysis?.verdict || analysis.verdict === 'caution') { @@ -49,6 +50,10 @@ export default function ScanResultCautionScreen() { }, [analysis, url]); const handleSave = (title: string) => { + if (!canSave) { + return; + } + // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 addLink({ id: Date.now(), @@ -139,7 +144,12 @@ export default function ScanResultCautionScreen() { {/* 버튼 영역 */} - setSaveModalVisible(true)} activeOpacity={0.8}> + setSaveModalVisible(true)} + activeOpacity={0.8} + disabled={!canSave} + > 주의 후 저장 @@ -238,6 +248,9 @@ const styles = StyleSheet.create({ ...Typography.section, color: Colors.brand.text, }, + disabledButton: { + opacity: 0.45, + }, secondaryButton: { width: '100%', height: 56, diff --git a/app/(tabs)/(home)/scan-result.tsx b/app/(tabs)/(home)/scan-result.tsx index 5b74c0c..9eca83a 100644 --- a/app/(tabs)/(home)/scan-result.tsx +++ b/app/(tabs)/(home)/scan-result.tsx @@ -39,6 +39,7 @@ export default function ScanResultScreen() { const [saveModalVisible, setSaveModalVisible] = useState(false); const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'safe'); const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); + const canSave = displayUrl.trim().length > 0 && !isLoading && !shouldRedirectToVerdict; useEffect(() => { if (!analysis?.verdict || analysis.verdict === 'safe') { @@ -56,6 +57,10 @@ export default function ScanResultScreen() { }, [analysis, url]); const handleSave = (title: string) => { + if (!canSave) { + return; + } + // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체 // 현재는 URL 기반 mock 데이터로 즉시 추가 addLink({ @@ -147,7 +152,12 @@ export default function ScanResultScreen() { {/* 버튼 영역 */} - setSaveModalVisible(true)} activeOpacity={0.8}> + setSaveModalVisible(true)} + activeOpacity={0.8} + disabled={!canSave} + > 저장 @@ -250,6 +260,9 @@ const styles = StyleSheet.create({ ...Typography.section, color: Colors.brand.onPrimary, }, + disabledButton: { + opacity: 0.45, + }, secondaryButton: { width: '100%', height: 56,