From 23fad85c7804a5853517511da60970517dfca4dc Mon Sep 17 00:00:00 2001 From: kxxheehxxn Date: Thu, 11 Sep 2025 21:18:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20API=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=81=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ssh/src/pages/Voicephishing/index.tsx | 364 +++++++++++++++++++++++--- 1 file changed, 332 insertions(+), 32 deletions(-) diff --git a/ssh/src/pages/Voicephishing/index.tsx b/ssh/src/pages/Voicephishing/index.tsx index 83cacab..a96790f 100644 --- a/ssh/src/pages/Voicephishing/index.tsx +++ b/ssh/src/pages/Voicephishing/index.tsx @@ -1,6 +1,25 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { Phone, CreditCard, ShieldAlert } from 'lucide-react'; +// index.tsx 상단 util 추가 +const onlyDigits = (v: string) => v.replace(/\D/g, ''); + +const normalizeForType = (value: string, type: 'phone' | 'account') => { + const digits = onlyDigits(value); + if (type === 'phone') return digits; // 010xxxxxxxx 형태 + return digits; // 계좌도 숫자만 +}; + +const isValid = (value: string, type: 'phone' | 'account') => { + const digits = onlyDigits(value); + if (type === 'phone') { + // 010/011/016/017/018/019 + 7~8자리 + return /^01[016789]\d{7,8}$/.test(digits); + } + // 계좌는 은행별 길이가 달라 느슨히 최소 8자리 이상으로 체크 + return /^\d{8,20}$/.test(digits); +}; + const VoicephishingPage = () => { const [input, setInput] = useState(''); const [type, setType] = useState<'phone' | 'account'>('phone'); @@ -13,27 +32,193 @@ const VoicephishingPage = () => { type: 'phone' | 'account'; value: string; reports: number; - lastReported?: string; - source?: string; - risk?: 'low' | 'medium' | 'high'; + lastReported: string; + risk: 'low' | 'medium' | 'high'; }> >([]); const [hasSearched, setHasSearched] = useState(false); + const [reportingId, setReportingId] = useState(null); + const [notice, setNotice] = useState(null); + + const [recentReports, setRecentReports] = useState< + Array<{ + id: string; + type: 'phone' | 'account'; + value: string; + lastReported: string; + }> + >([]); + + useEffect(() => { + let aborted = false; + + const load = async () => { + try { + const res = await fetch('/api/reports/recent'); + if (!res.ok) throw new Error(); + if (res.status === 204) { + // No Content + if (!aborted) setRecentReports([]); + return; + } + const data = await res.json(); + let list: any[] = []; + if (Array.isArray(data)) list = data; + else if (Array.isArray((data as any)?.data?.items)) + list = (data as any).data.items; // ✅ support nested items + else if (Array.isArray((data as any)?.data)) list = (data as any).data; + else if (Array.isArray((data as any)?.content)) list = (data as any).content; + else if (Array.isArray((data as any)?.items)) list = (data as any).items; + else if (data && typeof data === 'object') list = [data]; + else list = []; + + const mapped = list.map((item: any) => ({ + id: item.id ?? '', + type: item.type === 'phone' || item.type === 'account' ? item.type : 'phone', + value: item.value ?? '', + lastReported: item.lastReported + ? new Date(item.lastReported).toISOString().split('T')[0] + : '-', + })); + if (!aborted) setRecentReports(mapped); + } catch { + if (!aborted) setRecentReports([]); + } + }; + + load(); + const t = setInterval(load, 15000); // 15초마다 갱신 + return () => { + aborted = true; + clearInterval(t); + }; + }, []); + + // 타입 전환 시 입력 및 상태 초기화 + const switchType = useCallback((next: 'phone' | 'account') => { + setType(next); + setInput(''); + setResults([]); + setHasSearched(false); + setError(null); + setNotice(null); + setReportingId(null); + }, []); + + const submitReport = useCallback( + async (payload: { type: 'phone' | 'account'; value: string }) => { + const key = `${payload.type}:${payload.value}`; + setReportingId(key); + setError(null); + setNotice(null); + try { + const res = await fetch('/api/reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || '신고에 실패했습니다. 다시 시도해 주세요.'); + } + setNotice('신고가 접수되었습니다. 감사합니다.'); + // 신고 접수 후, 최근 신고 목록을 즉시 갱신 + try { + const recentRes = await fetch('/api/reports/recent'); + if (recentRes.ok && recentRes.status !== 204) { + const data = await recentRes.json(); + let list: any[] = []; + if (Array.isArray(data)) list = data; + else if (Array.isArray((data as any)?.data)) list = (data as any).data; + else if (Array.isArray((data as any)?.content)) list = (data as any).content; + else if (Array.isArray((data as any)?.items)) list = (data as any).items; + else if (data && typeof data === 'object') list = [data]; + const mapped = list.map((item: any) => ({ + id: item.id ?? '', + type: item.type === 'phone' || item.type === 'account' ? item.type : 'phone', + value: item.value ?? '', + lastReported: item.lastReported + ? new Date(item.lastReported).toISOString().split('T')[0] + : '-', + })); + setRecentReports(mapped); + } + } catch {} + } catch (e: any) { + setError(e?.message || '신고 처리 중 오류가 발생했습니다.'); + } finally { + setReportingId(null); + } + }, + [], + ); const handleLookup = useCallback(() => { - const q = input.trim(); + const raw = input.trim(); setHasSearched(true); setError(null); - if (!q) { + if (!raw) { setResults([]); setIsLoading(false); setError(type === 'phone' ? '전화번호를 입력해 주세요.' : '계좌번호를 입력해 주세요.'); return; } + + const q = normalizeForType(raw, type); + if (!isValid(q, type)) { + setResults([]); + setIsLoading(false); + setError( + type === 'phone' ? '전화번호 형식을 확인해 주세요.' : '계좌번호 형식을 확인해 주세요.', + ); + return; + } + setIsLoading(true); - // 백엔드 연동 전이므로 즉시 완료 처리 - setResults([]); - setIsLoading(false); + fetch(`/api/lookup?type=${type}&q=${encodeURIComponent(q)}`) + .then(async (res) => { + if (!res.ok) { + // 백엔드가 400과 함께 메시지를 주면 노출 + const text = await res.text().catch(() => ''); + throw new Error(text || '조회에 실패했습니다. 다시 시도해 주세요.'); + } + const data = await res.json(); + let list: any[] = []; + if (Array.isArray(data)) list = data; + else if (Array.isArray((data as any)?.data?.items)) + list = (data as any).data.items; // ✅ support nested items in { data: { items: [...] } } + else if (Array.isArray((data as any)?.data)) + list = (data as any).data; // e.g., { data: [...] } + else if (Array.isArray((data as any)?.content)) + list = (data as any).content; // e.g., Spring Page + else if (Array.isArray((data as any)?.items)) list = (data as any).items; + else if (data && typeof data === 'object') + list = [data]; // single object -> wrap + else list = []; + + const mappedResults = list.map((item: any) => ({ + id: item.id ?? '', + type: item.type === 'phone' || item.type === 'account' ? item.type : type, + value: item.value ?? q, + reports: typeof item.reports === 'number' ? item.reports : 0, + lastReported: item.lastReported + ? new Date(item.lastReported).toISOString().split('T')[0] + : '-', + risk: (['low', 'medium', 'high'] as const).includes(item.risk) + ? item.risk + : typeof item.reports === 'number' && item.reports >= 10 + ? 'high' + : typeof item.reports === 'number' && item.reports >= 3 + ? 'medium' + : 'low', + })); + setResults(mappedResults); + }) + .catch((err) => { + setError(err.message || '조회 중 오류가 발생했습니다.'); + setResults([]); + }) + .finally(() => setIsLoading(false)); }, [input, type]); return ( @@ -73,7 +258,7 @@ const VoicephishingPage = () => { + @@ -133,9 +335,16 @@ const VoicephishingPage = () => { {error} )} + {!isLoading && !error && notice && ( +
+ {notice} +
+ )} {!isLoading && !error && hasSearched && results.length === 0 && (
- 검색 결과가 없습니다. +
+ 검색 결과가 없습니다. +
)} {!isLoading && !error && results.length > 0 && ( @@ -152,13 +361,23 @@ const VoicephishingPage = () => {
신고 횟수
{r.reports}건
+
+ {r.risk === 'high' ? '높음' : r.risk === 'medium' ? '중간' : '낮음'} +
{r.lastReported ? `최근 신고일: ${r.lastReported}` : '최근 신고일: -'} - {r.source ? `출처: ${r.source}` : ''}
))} @@ -167,12 +386,7 @@ const VoicephishingPage = () => {
- } - /> + ; +}) => { + const [index, setIndex] = useState(0); + + // 보고서 목록이 변경되면 인덱스 보정 + useEffect(() => { + if (index >= reports.length) setIndex(0); + }, [reports, index]); + + const hasItems = reports && reports.length > 0; + const current = hasItems ? reports[index] : null; + + return ( +
+
+
+ + + + 최근 신고 데이터 +
+
+ {hasItems ? `${index + 1} / ${Math.min(5, reports.length)}` : '0 / 0'} +
+
+ + {!hasItems ? ( +
최근 신고 데이터가 없습니다.
+ ) : ( +
+ {/* 단일 카드 */} +
+
+ {current!.type === 'phone' ? '전화번호' : '계좌번호'} +
+
{current!.value}
+
+ 최근 신고일: {current!.lastReported || '-'} +
+
+ + {/* 내비게이션 */} +
+ +
+ {reports.slice(0, 5).map((_, i) => ( + + ))} +
+ +
+
+ )} +
+ ); +}; + export default VoicephishingPage; From 33b820cf8108da326c3483a3ad0220a834137fc6 Mon Sep 17 00:00:00 2001 From: kxxheehxxn Date: Thu, 11 Sep 2025 21:22:49 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ssh/src/pages/Voicephishing/index.tsx | 28 ++++++++------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/ssh/src/pages/Voicephishing/index.tsx b/ssh/src/pages/Voicephishing/index.tsx index a96790f..41c833b 100644 --- a/ssh/src/pages/Voicephishing/index.tsx +++ b/ssh/src/pages/Voicephishing/index.tsx @@ -1,22 +1,19 @@ import { useState, useCallback, useEffect } from 'react'; import { Phone, CreditCard, ShieldAlert } from 'lucide-react'; -// index.tsx 상단 util 추가 const onlyDigits = (v: string) => v.replace(/\D/g, ''); const normalizeForType = (value: string, type: 'phone' | 'account') => { const digits = onlyDigits(value); - if (type === 'phone') return digits; // 010xxxxxxxx 형태 - return digits; // 계좌도 숫자만 + if (type === 'phone') return digits; + return digits; }; const isValid = (value: string, type: 'phone' | 'account') => { const digits = onlyDigits(value); if (type === 'phone') { - // 010/011/016/017/018/019 + 7~8자리 return /^01[016789]\d{7,8}$/.test(digits); } - // 계좌는 은행별 길이가 달라 느슨히 최소 8자리 이상으로 체크 return /^\d{8,20}$/.test(digits); }; @@ -64,8 +61,7 @@ const VoicephishingPage = () => { const data = await res.json(); let list: any[] = []; if (Array.isArray(data)) list = data; - else if (Array.isArray((data as any)?.data?.items)) - list = (data as any).data.items; // ✅ support nested items + else if (Array.isArray((data as any)?.data?.items)) list = (data as any).data.items; else if (Array.isArray((data as any)?.data)) list = (data as any).data; else if (Array.isArray((data as any)?.content)) list = (data as any).content; else if (Array.isArray((data as any)?.items)) list = (data as any).items; @@ -87,14 +83,13 @@ const VoicephishingPage = () => { }; load(); - const t = setInterval(load, 15000); // 15초마다 갱신 + const t = setInterval(load, 15000); return () => { aborted = true; clearInterval(t); }; }, []); - // 타입 전환 시 입력 및 상태 초기화 const switchType = useCallback((next: 'phone' | 'account') => { setType(next); setInput(''); @@ -122,7 +117,6 @@ const VoicephishingPage = () => { throw new Error(text || '신고에 실패했습니다. 다시 시도해 주세요.'); } setNotice('신고가 접수되었습니다. 감사합니다.'); - // 신고 접수 후, 최근 신고 목록을 즉시 갱신 try { const recentRes = await fetch('/api/reports/recent'); if (recentRes.ok && recentRes.status !== 204) { @@ -178,22 +172,17 @@ const VoicephishingPage = () => { fetch(`/api/lookup?type=${type}&q=${encodeURIComponent(q)}`) .then(async (res) => { if (!res.ok) { - // 백엔드가 400과 함께 메시지를 주면 노출 const text = await res.text().catch(() => ''); throw new Error(text || '조회에 실패했습니다. 다시 시도해 주세요.'); } const data = await res.json(); let list: any[] = []; if (Array.isArray(data)) list = data; - else if (Array.isArray((data as any)?.data?.items)) - list = (data as any).data.items; // ✅ support nested items in { data: { items: [...] } } - else if (Array.isArray((data as any)?.data)) - list = (data as any).data; // e.g., { data: [...] } - else if (Array.isArray((data as any)?.content)) - list = (data as any).content; // e.g., Spring Page + else if (Array.isArray((data as any)?.data?.items)) list = (data as any).data.items; + else if (Array.isArray((data as any)?.data)) list = (data as any).data; + else if (Array.isArray((data as any)?.content)) list = (data as any).content; else if (Array.isArray((data as any)?.items)) list = (data as any).items; - else if (data && typeof data === 'object') - list = [data]; // single object -> wrap + else if (data && typeof data === 'object') list = [data]; else list = []; const mappedResults = list.map((item: any) => ({ @@ -449,7 +438,6 @@ const RecentReportsCard = ({ }) => { const [index, setIndex] = useState(0); - // 보고서 목록이 변경되면 인덱스 보정 useEffect(() => { if (index >= reports.length) setIndex(0); }, [reports, index]);