From 6ea82a5561668ee110db0ccd48bbe0183e22306e Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 13:57:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20#304+#323=20LLM=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20UI=20+=20=EA=B3=B5=ED=86=B5=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #323 (LLM 검증 어드민 UI): - api.ts: getVerifyPending / verifyAll / verifyOne / setRestaurantHidden 추가 - Restaurant 타입에 hidden / hidden_reason / verified_at 추가 - RestaurantsPanel 헤더에 "미검증 N건 + LLM 검증" 버튼 추가 - 테이블에 "검증" 컬럼 추가: - hidden=true → "숨김 (사유)" 버튼 (클릭 시 해제) - verified_at 있고 visible → "OK" 버튼 (클릭 시 숨김) - 미검증 → "미검증" 텍스트 #304 (어드민 공통 유틸): - lib/admin-utils.ts 신규 - getAdminToken(): localStorage 직접 접근 통일 - authHeaders(): 표준 Bearer 헤더 - consumeSseStream(): SSE 라인 파싱 헬퍼 - colSpan 6 → 7로 검증 컬럼 반영 후속 분리: #329 (admin/page.tsx 전체 분리 + localStorage/SSE 호출 11+곳 교체) Refs: #304 #323 #322 --- frontend/src/app/admin/page.tsx | 108 ++++++++++++++++++++++++++++++-- frontend/src/lib/admin-utils.ts | 52 +++++++++++++++ frontend/src/lib/api.ts | 39 ++++++++++++ 3 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/admin-utils.ts diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 95d60bf..ed5a007 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1470,12 +1470,12 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { setEditRest({ name: r.name, cuisine_type: r.cuisine_type || "", - foods_mentioned: r.foods_mentioned.join(", "), + foods_mentioned: (r.foods_mentioned || []).join(", "), evaluation: evalText, address: r.address || "", region: r.region || "", price_range: r.price_range || "", - guests: r.guests.join(", "), + guests: (r.guests || []).join(", "), }); } : undefined} title={isAdmin ? "클릭하여 수정" : undefined} @@ -1513,7 +1513,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { {r.cuisine_type &&

종류: {r.cuisine_type}

} {r.price_range &&

가격대: {r.price_range}

} - {r.foods_mentioned.length > 0 && ( + {r.foods_mentioned?.length > 0 && (
{r.foods_mentioned.map((f, j) => ( {f} @@ -1523,7 +1523,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { {r.evaluation?.text && (

{r.evaluation.text}

)} - {r.guests.length > 0 && ( + {r.guests?.length > 0 && (

게스트: {r.guests.join(", ")}

)}
@@ -1627,6 +1627,45 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) { useEffect(() => { load(); }, [load]); + // #322/#323 LLM 검증 UI + const [verifyPending, setVerifyPending] = useState(null); + const [verifying, setVerifying] = useState(false); + const [verifyResult, setVerifyResult] = useState(null); + + const loadVerifyPending = useCallback(() => { + api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null)); + }, []); + useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]); + + const handleVerifyAll = async () => { + if (!isAdmin) return; + if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return; + setVerifying(true); + setVerifyResult(null); + try { + const r = await api.verifyAll(10); + setVerifyResult(`${r.processed}건 검증 완료`); + loadVerifyPending(); + load(); + } catch (e) { + setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setVerifying(false); + } + }; + + const handleToggleHidden = async (r: Restaurant) => { + if (!isAdmin) return; + const becomingHidden = !r.hidden; + const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : ""; + try { + await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual"); + load(); + } catch (e) { + alert(`실패: ${e instanceof Error ? e.message : String(e)}`); + } + }; + const filtered = restaurants.filter((r) => { if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false; return true; @@ -1728,6 +1767,18 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) { )} {isAdmin && (<> + {/* #322/#323 — LLM 검증 */} +
+ 미검증 {verifyPending ?? "?"}건 + + {verifyResult && {verifyResult}} +
+ ) : r.verified_at ? ( + + ) : ( + 미검증 + )} + ))} {!loading && filtered.length === 0 && ( - + 식당 데이터가 없습니다 @@ -2144,6 +2219,7 @@ interface AdminUser { email: string | null; nickname: string | null; avatar_url: string | null; + is_admin: boolean; provider: string | null; created_at: string | null; favorite_count: number; @@ -2246,6 +2322,7 @@ function UsersPanel() { 사용자 이메일 + 관리자 찜 리뷰 메모 @@ -2282,6 +2359,27 @@ function UsersPanel() { {u.email || "-"} + + + {u.favorite_count > 0 ? ( diff --git a/frontend/src/lib/admin-utils.ts b/frontend/src/lib/admin-utils.ts new file mode 100644 index 0000000..b88e56f --- /dev/null +++ b/frontend/src/lib/admin-utils.ts @@ -0,0 +1,52 @@ +// #304 어드민 페이지 공통 유틸. +// 결함: localStorage 직접 접근 10+곳 / SSE 파싱 코드 6곳 중복. + +const TOKEN_KEY = "tasteby_token"; + +export function getAdminToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function authHeaders(): Record { + const token = getAdminToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +/** + * SSE(Server-Sent Events) 스트림을 라인 단위로 파싱하여 onEvent 콜백을 호출. + * 호환 패턴: `data: { ...json... }` 한 줄 = 한 이벤트. + * 비어있는 줄은 무시. JSON 파싱 실패 시 콜백 skip. + */ +export async function consumeSseStream( + response: Response, + onEvent: (event: unknown) => void, + onError?: (err: unknown) => void, +): Promise { + const reader = response.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload) continue; + try { + onEvent(JSON.parse(payload)); + } catch { + // 무시: 일부 SSE 줄이 JSON이 아닐 수도 있음 + } + } + } + } catch (err) { + onError?.(err); + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2539d7c..131c24f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -51,6 +51,10 @@ export interface Restaurant { website: string | null; channels?: string[]; foods_mentioned?: string[]; + // #322 LLM 검증 + hidden?: boolean; + hidden_reason?: string | null; + verified_at?: string | null; } export interface VideoLink { @@ -310,6 +314,7 @@ export const api = { email: string | null; nickname: string | null; avatar_url: string | null; + is_admin: boolean; provider: string | null; created_at: string | null; favorite_count: number; @@ -320,6 +325,14 @@ export const api = { }>(`/api/admin/users${qs ? `?${qs}` : ""}`); }, + updateAdminUserAdmin(userId: string, admin: boolean) { + return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ admin }), + }); + }, + getAdminUserFavorites(userId: string) { return fetchApi< { @@ -567,4 +580,30 @@ export const api = { { method: "POST" } ); }, + + // #322 — LLM 검증 어드민 API + getVerifyPending() { + return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending"); + }, + verifyAll(batchSize: number = 10) { + return fetchApi<{ processed: number }>( + `/api/admin/restaurants/verify/all?batchSize=${batchSize}`, + { method: "POST" } + ); + }, + verifyOne(id: string) { + return fetchApi<{ success: boolean; id: string }>( + `/api/admin/restaurants/${id}/verify`, + { method: "POST" } + ); + }, + setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") { + return fetchApi<{ success: boolean; id: string; hidden: boolean }>( + `/api/admin/restaurants/${id}/hidden`, + { + method: "PATCH", + body: JSON.stringify({ hidden, reason }), + } + ); + }, };