Compare commits

...

2 Commits

Author SHA1 Message Date
joungmin
8152b71119 docs(changelog): v0.1.42 #351 SSE 통일 기록 2026-06-15 17:17:14 +09:00
joungmin
d6ee62230e 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
2026-06-15 17:15:35 +09:00
3 changed files with 81 additions and 149 deletions

View File

@@ -6,6 +6,13 @@
## 2026-06-15 ## 2026-06-15
### 🧹 #351 admin SSE 6곳 consumeSseStream 통일 (v0.1.42)
- VideosPanel 4곳(bulkTranscript/Extract, rebuildVectors, remapCuisine, remapFoods)
- RestaurantsPanel 2곳(bulkTabling, bulkCatchtable)
- response.body?.getReader 직접 호출 0건 (lib/admin-utils.ts의 consumeSseStream 활용)
- 149줄 삭제 → 74줄 압축, npm test 13/13 통과
- Refs: #351 (close)
### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40) ### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40)
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입 - Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입
- next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv) - next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv)

View File

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

View File

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