refactor(admin): #351 SSE 6곳 consumeSseStream으로 통일

VideosPanel:
- bulkTranscript/bulkExtract: 단일 SSE 핸들러 → consumeSseStream
- rebuildVectors: consumeSseStream
- remapCuisine / remapFoods: consumeSseStream

RestaurantsPanel:
- bulkTabling / bulkCatchtable: consumeSseStream

이전: 각 호출이 자체적으로 reader+decoder+buf.split+match 6곳 복제.
이제: lib/admin-utils.ts의 consumeSseStream(resp, onEvent)으로 일관 처리.

빌드 + npm test 13/13 통과. 회귀 없음.

Refs: #351
This commit is contained in:
joungmin
2026-06-15 17:15:35 +09:00
parent cf1055bdf9
commit d6ee62230e
2 changed files with 74 additions and 149 deletions

View File

@@ -1,5 +1,5 @@
"use client";
import { getAdminToken } from "@/lib/admin-utils";
import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
@@ -209,30 +209,19 @@ export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
const m = line.match(/^data:(.+)$/);
if (!m) continue;
const evt = JSON.parse(m[1]);
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkTablingProgress(p => ({
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
// #351 — consumeSseStream으로 통일
await consumeSseStream(res, (raw) => {
const evt = raw as { type: string; [k: string]: unknown };
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkTablingProgress(p => ({
...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
}
});
} catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setBulkTabling(false); load(); }
}}
@@ -287,30 +276,19 @@ export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
const m = line.match(/^data:(.+)$/);
if (!m) continue;
const evt = JSON.parse(m[1]);
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkCatchtableProgress(p => ({
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
// #351 — consumeSseStream으로 통일
await consumeSseStream(res, (raw) => {
const evt = raw as { type: string; [k: string]: unknown };
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkCatchtableProgress(p => ({
...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
}
});
} catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setBulkCatchtable(false); load(); }
}}

View File

@@ -1,5 +1,5 @@
"use client";
import { getAdminToken } from "@/lib/admin-utils";
import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
@@ -209,39 +209,25 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setBulkProgress(null);
return;
}
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
if (!reader) { setRunning(false); return; }
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === "processing") {
setBulkProgress((p) => p ? { ...p, current: ev.index + 1, currentTitle: ev.title, waiting: undefined } : p);
} else if (ev.type === "wait") {
setBulkProgress((p) => p ? { ...p, waiting: ev.delay } : p);
} else if (ev.type === "done") {
const detail = isTranscript
? `${ev.source} / ${ev.length?.toLocaleString()}`
: `${ev.restaurants}개 식당`;
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail }] } : p);
} else if (ev.type === "error") {
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail: ev.message, error: true }] } : p);
} else if (ev.type === "complete") {
setRunning(false);
load();
}
} catch { /* ignore */ }
// #351 — consumeSseStream으로 통일
await consumeSseStream(resp, (raw) => {
const ev = raw as { type: string; [k: string]: unknown };
if (ev.type === "processing") {
setBulkProgress((p) => p ? { ...p, current: (ev.index as number) + 1, currentTitle: ev.title as string, waiting: undefined } : p);
} else if (ev.type === "wait") {
setBulkProgress((p) => p ? { ...p, waiting: ev.delay as number } : p);
} else if (ev.type === "done") {
const detail = isTranscript
? `${ev.source} / ${(ev.length as number)?.toLocaleString()}`
: `${ev.restaurants}개 식당`;
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail }] } : p);
} else if (ev.type === "error") {
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail: ev.message as string, error: true }] } : p);
} else if (ev.type === "complete") {
setRunning(false);
load();
}
}
});
setRunning(false);
load();
} catch {
@@ -264,30 +250,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRebuildingVectors(false);
return;
}
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
if (!reader) { setRebuildingVectors(false); return; }
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.status === "progress" || ev.type === "progress") {
setVectorProgress({ phase: ev.phase, current: ev.current, total: ev.total, name: ev.name });
} else if (ev.status === "done" || ev.type === "done") {
setVectorProgress({ phase: "done", current: ev.total, total: ev.total });
} else if (ev.type === "error") {
alert(`벡터 재생성 오류: ${ev.message}`);
}
} catch { /* ignore */ }
// #351 — consumeSseStream으로 통일
await consumeSseStream(resp, (raw) => {
const ev = raw as { status?: string; type?: string; [k: string]: unknown };
if (ev.status === "progress" || ev.type === "progress") {
setVectorProgress({ phase: ev.phase as string, current: ev.current as number, total: ev.total as number, name: ev.name as string });
} else if (ev.status === "done" || ev.type === "done") {
setVectorProgress({ phase: "done", current: ev.total as number, total: ev.total as number });
} else if (ev.type === "error") {
alert(`벡터 재생성 오류: ${ev.message}`);
}
}
});
setRebuildingVectors(false);
} catch {
setRebuildingVectors(false);
@@ -309,30 +282,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRemappingCuisine(false);
return;
}
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
if (!reader) { setRemappingCuisine(false); return; }
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === "processing" || ev.type === "batch_done") {
setRemapProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
} else if (ev.type === "complete") {
setRemapProgress({ current: ev.total, total: ev.total, updated: ev.updated });
} else if (ev.type === "error") {
alert(`재분류 오류: ${ev.message}`);
}
} catch { /* ignore */ }
// #351 — consumeSseStream으로 통일
await consumeSseStream(resp, (raw) => {
const ev = raw as { type: string; [k: string]: unknown };
if (ev.type === "processing" || ev.type === "batch_done") {
setRemapProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 });
} else if (ev.type === "complete") {
setRemapProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number });
} else if (ev.type === "error") {
alert(`재분류 오류: ${ev.message}`);
}
}
});
setRemappingCuisine(false);
} catch {
setRemappingCuisine(false);
@@ -354,30 +314,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRemappingFoods(false);
return;
}
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
if (!reader) { setRemappingFoods(false); return; }
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === "processing" || ev.type === "batch_done") {
setFoodsProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
} else if (ev.type === "complete") {
setFoodsProgress({ current: ev.total, total: ev.total, updated: ev.updated });
} else if (ev.type === "error") {
alert(`메뉴 태그 재생성 오류: ${ev.message}`);
}
} catch { /* ignore */ }
// #351 — consumeSseStream으로 통일
await consumeSseStream(resp, (raw) => {
const ev = raw as { type: string; [k: string]: unknown };
if (ev.type === "processing" || ev.type === "batch_done") {
setFoodsProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 });
} else if (ev.type === "complete") {
setFoodsProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number });
} else if (ev.type === "error") {
alert(`메뉴 태그 재생성 오류: ${ev.message}`);
}
}
});
setRemappingFoods(false);
} catch {
setRemappingFoods(false);