Compare commits
3 Commits
7fa623d22d
...
v0.1.18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea82a5561 | ||
|
|
04c54d1b1a | ||
|
|
4407f2d67d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ k8s/secrets.yaml
|
|||||||
# OS / misc
|
# OS / misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
backend/cookies.txt
|
backend/cookies.txt
|
||||||
|
backend-java/cookies.txt
|
||||||
|
**/cookies.txt
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -6,6 +6,16 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
||||||
|
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
||||||
|
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
||||||
|
- PipelineService: geocode 실패 시 좌표/place_id/주소 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
|
||||||
|
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에서 제거 ('한국||구' 깨짐 방지)
|
||||||
|
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
|
||||||
|
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt
|
||||||
|
- 후속 분리: #325 (#291 잔여 MINOR), #326 (parseJson 최적화 + #292 MINOR)
|
||||||
|
- Refs: #291 #292 (close)
|
||||||
|
|
||||||
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
|
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
|
||||||
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
|
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
|
||||||
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
|
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public class ExtractorService {
|
|||||||
%s
|
%s
|
||||||
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
||||||
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
||||||
- evaluation: 평가 내용 (string | null)
|
- evaluation: 평가 내용을 100자 이내로 요약 (string | null)
|
||||||
- guests: 함께한 게스트 (string[])
|
- guests: 함께한 게스트 (string[])
|
||||||
|
|
||||||
영상 제목: {title}
|
영상 제목: {title}
|
||||||
@@ -62,6 +62,10 @@ public class ExtractorService {
|
|||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
|
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
|
||||||
|
// #292 — transcript null/blank 가드 (NPE 방지)
|
||||||
|
if (transcript == null || transcript.isBlank()) {
|
||||||
|
return new ExtractionResult(List.of(), "");
|
||||||
|
}
|
||||||
// Truncate very long transcripts
|
// Truncate very long transcripts
|
||||||
if (transcript.length() > 8000) {
|
if (transcript.length() > 8000) {
|
||||||
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
|
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
|
||||||
|
|||||||
@@ -156,7 +156,15 @@ public class GeocodingService {
|
|||||||
|
|
||||||
if (country.isEmpty() && !city.isEmpty()) country = "한국";
|
if (country.isEmpty() && !city.isEmpty()) country = "한국";
|
||||||
if (country.isEmpty()) return null;
|
if (country.isEmpty()) return null;
|
||||||
return country + "|" + city + "|" + district;
|
// #292 — 빈 토큰은 region 문자열에 포함시키지 않는다(`한국||구` 형식 방지).
|
||||||
|
StringBuilder sb = new StringBuilder(country);
|
||||||
|
if (!city.isEmpty()) {
|
||||||
|
sb.append('|').append(city);
|
||||||
|
if (!district.isEmpty()) sb.append('|').append(district);
|
||||||
|
} else if (!district.isEmpty()) {
|
||||||
|
// city 없이 district만 있는 경우는 정확도 낮으므로 무시
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> geocode(String query) {
|
private Map<String, Object> geocode(String query) {
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ public class PipelineService {
|
|||||||
String videoDbId = (String) video.get("id");
|
String videoDbId = (String) video.get("id");
|
||||||
String title = (String) video.get("title");
|
String title = (String) video.get("title");
|
||||||
|
|
||||||
|
// #292 — 외부 가시성을 위해 진입 시 processing 전이 (이미 processing이면 no-op)
|
||||||
|
updateVideoStatus(videoDbId, "processing", null, null);
|
||||||
|
|
||||||
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
|
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
|
||||||
if (result.restaurants().isEmpty()) {
|
if (result.restaurants().isEmpty()) {
|
||||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||||
@@ -105,18 +108,26 @@ public class PipelineService {
|
|||||||
// Build upsert data
|
// Build upsert data
|
||||||
var data = new HashMap<String, Object>();
|
var data = new HashMap<String, Object>();
|
||||||
data.put("name", name);
|
data.put("name", name);
|
||||||
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
|
|
||||||
data.put("region", restData.get("region"));
|
data.put("region", restData.get("region"));
|
||||||
data.put("latitude", geo != null ? geo.get("latitude") : null);
|
|
||||||
data.put("longitude", geo != null ? geo.get("longitude") : null);
|
|
||||||
data.put("cuisine_type", restData.get("cuisine_type"));
|
data.put("cuisine_type", restData.get("cuisine_type"));
|
||||||
data.put("price_range", restData.get("price_range"));
|
data.put("price_range", restData.get("price_range"));
|
||||||
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
|
// #292 — geocode 실패(geo==null) 시 좌표/주소/place_id 등 기존 값 보존하기 위해
|
||||||
data.put("phone", geo != null ? geo.get("phone") : null);
|
// null을 명시적으로 put하지 않는다. upsert 측에서 누락 컬럼은 그대로 유지.
|
||||||
data.put("website", geo != null ? geo.get("website") : null);
|
if (geo != null) {
|
||||||
data.put("business_status", geo != null ? geo.get("business_status") : null);
|
data.put("address", geo.get("formatted_address"));
|
||||||
data.put("rating", geo != null ? geo.get("rating") : null);
|
data.put("latitude", geo.get("latitude"));
|
||||||
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
|
data.put("longitude", geo.get("longitude"));
|
||||||
|
data.put("google_place_id", geo.get("google_place_id"));
|
||||||
|
data.put("phone", geo.get("phone"));
|
||||||
|
data.put("website", geo.get("website"));
|
||||||
|
data.put("business_status", geo.get("business_status"));
|
||||||
|
data.put("rating", geo.get("rating"));
|
||||||
|
data.put("rating_count", geo.get("rating_count"));
|
||||||
|
} else {
|
||||||
|
// geocode 실패한 첫 등록 케이스에서 최소한의 주소(LLM이 추출한 원시값)는 저장
|
||||||
|
Object rawAddr = restData.get("address");
|
||||||
|
if (rawAddr != null) data.put("address", rawAddr);
|
||||||
|
}
|
||||||
|
|
||||||
String restId = restaurantService.upsert(data);
|
String restId = restaurantService.upsert(data);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public class VideoService {
|
|||||||
VideoDetail detail = mapper.findDetail(id);
|
VideoDetail detail = mapper.findDetail(id);
|
||||||
if (detail == null) return null;
|
if (detail == null) return null;
|
||||||
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
|
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
|
||||||
|
if (restaurants != null) {
|
||||||
|
restaurants.forEach(r -> r.setEvaluation(JsonUtil.normalizeEvaluation(r.getEvaluation())));
|
||||||
|
}
|
||||||
detail.setRestaurants(restaurants != null ? restaurants : List.of());
|
detail.setRestaurants(restaurants != null ? restaurants : List.of());
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ public class VideoService {
|
|||||||
mapper.cleanupOrphanRestaurant(restaurantId);
|
mapper.cleanupOrphanRestaurant(restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
|
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
|
||||||
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
|
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
|
||||||
int saved = 0;
|
int saved = 0;
|
||||||
|
|||||||
@@ -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