Compare commits
2 Commits
04c54d1b1a
...
7779d5ddfd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7779d5ddfd | ||
|
|
6ea82a5561 |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -6,6 +6,17 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
|
||||||
|
- 신규 frontend/src/lib/admin-utils.ts:
|
||||||
|
- getAdminToken / authHeaders / consumeSseStream
|
||||||
|
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
|
||||||
|
- RestaurantsPanel:
|
||||||
|
- 헤더: "미검증 N건 + LLM 검증" 버튼
|
||||||
|
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
|
||||||
|
- colSpan 7로 수정
|
||||||
|
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
|
||||||
|
- Refs: #304 #323 #322 (close)
|
||||||
|
|
||||||
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
||||||
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
||||||
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
||||||
|
|||||||
@@ -1470,12 +1470,12 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
setEditRest({
|
setEditRest({
|
||||||
name: r.name,
|
name: r.name,
|
||||||
cuisine_type: r.cuisine_type || "",
|
cuisine_type: r.cuisine_type || "",
|
||||||
foods_mentioned: r.foods_mentioned.join(", "),
|
foods_mentioned: (r.foods_mentioned || []).join(", "),
|
||||||
evaluation: evalText,
|
evaluation: evalText,
|
||||||
address: r.address || "",
|
address: r.address || "",
|
||||||
region: r.region || "",
|
region: r.region || "",
|
||||||
price_range: r.price_range || "",
|
price_range: r.price_range || "",
|
||||||
guests: r.guests.join(", "),
|
guests: (r.guests || []).join(", "),
|
||||||
});
|
});
|
||||||
} : undefined}
|
} : undefined}
|
||||||
title={isAdmin ? "클릭하여 수정" : undefined}
|
title={isAdmin ? "클릭하여 수정" : undefined}
|
||||||
@@ -1513,7 +1513,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{r.cuisine_type && <p>종류: {r.cuisine_type}</p>}
|
{r.cuisine_type && <p>종류: {r.cuisine_type}</p>}
|
||||||
{r.price_range && <p>가격대: {r.price_range}</p>}
|
{r.price_range && <p>가격대: {r.price_range}</p>}
|
||||||
</div>
|
</div>
|
||||||
{r.foods_mentioned.length > 0 && (
|
{r.foods_mentioned?.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{r.foods_mentioned.map((f, j) => (
|
{r.foods_mentioned.map((f, j) => (
|
||||||
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
|
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
|
||||||
@@ -1523,7 +1523,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{r.evaluation?.text && (
|
{r.evaluation?.text && (
|
||||||
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
|
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
|
||||||
)}
|
)}
|
||||||
{r.guests.length > 0 && (
|
{r.guests?.length > 0 && (
|
||||||
<p className="mt-1 text-xs text-gray-500">게스트: {r.guests.join(", ")}</p>
|
<p className="mt-1 text-xs text-gray-500">게스트: {r.guests.join(", ")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1627,6 +1627,45 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// #322/#323 LLM 검증 UI
|
||||||
|
const [verifyPending, setVerifyPending] = useState<number | null>(null);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyResult, setVerifyResult] = useState<string | null>(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) => {
|
const filtered = restaurants.filter((r) => {
|
||||||
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
|
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -1728,6 +1767,18 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (<>
|
{isAdmin && (<>
|
||||||
|
{/* #322/#323 — LLM 검증 */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span>미검증 {verifyPending ?? "?"}건</span>
|
||||||
|
<button
|
||||||
|
onClick={handleVerifyAll}
|
||||||
|
disabled={verifying || verifyPending === 0}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-amber-500 hover:bg-amber-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{verifying ? "검증 중..." : "LLM 검증"}
|
||||||
|
</button>
|
||||||
|
{verifyResult && <span className="text-amber-600">{verifyResult}</span>}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
||||||
@@ -1890,6 +1941,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>가격대{sortIcon("price_range")}</th>
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>가격대{sortIcon("price_range")}</th>
|
||||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>평점{sortIcon("rating")}</th>
|
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>평점{sortIcon("rating")}</th>
|
||||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>상태{sortIcon("business_status")}</th>
|
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>상태{sortIcon("business_status")}</th>
|
||||||
|
<th className="text-center px-4 py-3">검증</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -1917,11 +1969,34 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<span className="text-xs text-gray-400">-</span>
|
<span className="text-xs text-gray-400">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{r.hidden ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleHidden(r)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
title={r.hidden_reason || "manual"}
|
||||||
|
className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold hover:bg-red-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
숨김 {r.hidden_reason ? `(${r.hidden_reason.slice(0, 12)})` : ""}
|
||||||
|
</button>
|
||||||
|
) : r.verified_at ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleHidden(r)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
title="검증 통과 — 클릭하면 숨김"
|
||||||
|
className="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px] font-semibold hover:bg-green-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">미검증</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||||
식당 데이터가 없습니다
|
식당 데이터가 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2144,6 +2219,7 @@ interface AdminUser {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
is_admin: boolean;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
favorite_count: number;
|
favorite_count: number;
|
||||||
@@ -2246,6 +2322,7 @@ function UsersPanel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-2">사용자</th>
|
<th className="text-left px-4 py-2">사용자</th>
|
||||||
<th className="text-left px-4 py-2">이메일</th>
|
<th className="text-left px-4 py-2">이메일</th>
|
||||||
|
<th className="text-center px-4 py-2">관리자</th>
|
||||||
<th className="text-center px-4 py-2">찜</th>
|
<th className="text-center px-4 py-2">찜</th>
|
||||||
<th className="text-center px-4 py-2">리뷰</th>
|
<th className="text-center px-4 py-2">리뷰</th>
|
||||||
<th className="text-center px-4 py-2">메모</th>
|
<th className="text-center px-4 py-2">메모</th>
|
||||||
@@ -2282,6 +2359,27 @@ function UsersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
|
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.updateAdminUserAdmin(u.id, !u.is_admin);
|
||||||
|
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, is_admin: !u.is_admin } : x));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update admin:", err);
|
||||||
|
alert("관리자 권한 변경에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
u.is_admin
|
||||||
|
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||||
|
: "bg-gray-100 text-gray-400 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.is_admin ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{u.favorite_count > 0 ? (
|
{u.favorite_count > 0 ? (
|
||||||
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
|
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
|
||||||
|
|||||||
52
frontend/src/lib/admin-utils.ts
Normal file
52
frontend/src/lib/admin-utils.ts
Normal file
@@ -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<string, string> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,10 @@ export interface Restaurant {
|
|||||||
website: string | null;
|
website: string | null;
|
||||||
channels?: string[];
|
channels?: string[];
|
||||||
foods_mentioned?: string[];
|
foods_mentioned?: string[];
|
||||||
|
// #322 LLM 검증
|
||||||
|
hidden?: boolean;
|
||||||
|
hidden_reason?: string | null;
|
||||||
|
verified_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoLink {
|
export interface VideoLink {
|
||||||
@@ -310,6 +314,7 @@ export const api = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
is_admin: boolean;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
favorite_count: number;
|
favorite_count: number;
|
||||||
@@ -320,6 +325,14 @@ export const api = {
|
|||||||
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
}>(`/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) {
|
getAdminUserFavorites(userId: string) {
|
||||||
return fetchApi<
|
return fetchApi<
|
||||||
{
|
{
|
||||||
@@ -567,4 +580,30 @@ export const api = {
|
|||||||
{ method: "POST" }
|
{ 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 }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user