o2o-site-AEO/solution/frontend/src/hooks/useLocalGuide.ts
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00

114 lines
4.1 KiB
TypeScript

import {useEffect, useState} from 'react';
import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types';
import {useBuilderStore} from '@/stores/builder';
export interface LocalGuideData {
foods: GuideCard[];
spots: GuideCard[];
festivals: FestivalCard[];
/** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */
syncedAt?: string;
}
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []};
// 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다.
const MAX_PER_TYPE = 20;
/** 서버 응답 항목 — 발행 payload 의 LocalPlace / FestivalEntry 와 같은 모양(백엔드 ResLocalGuide 주석). */
interface GuidePlace {
name: string;
category: string;
searchQuery: string;
distanceText?: string;
distanceMeters?: number;
imageUrl?: string;
location?: string;
description?: string;
}
interface GuideFestival extends GuidePlace {
month: string;
period?: string;
officialUrl?: string;
}
function toCard(p: GuidePlace): GuideCard {
return {
name: p.name,
// 설명이 없으면 주소로 대신한다 — 카드에 이름만 덜렁 있는 것보다 어디인지가 낫다.
description: p.description ?? p.location ?? '',
searchQuery: p.searchQuery,
imageUrl: p.imageUrl,
distanceMeters: typeof p.distanceMeters === 'number' ? p.distanceMeters : undefined,
distanceText: p.distanceText,
};
}
function toFestival(f: GuideFestival): FestivalCard {
return {
...toCard(f),
month: f.month,
period: f.period ?? '',
officialUrl: f.officialUrl,
};
}
// 사업장 하나당 한 번만 받는다 — 필터·캐러셀 조작마다 API 를 다시 때리지 않는다.
const cache = new Map<string, LocalGuideData>();
/**
* 업장 주변 가이드(맛집·명소·축제). 날씨(useWeather)와 같은 규약이다 —
* 실패해도 에러를 띄우지 않고 빈 목록을 유지한다. 부가 정보라 사장님 작업을 막을 이유가 없다.
*
* ★ 값은 서버(local.place_contents)가 소유하고, 발행본과 같은 필터·모양으로 온다.
* 캔버스가 보여주는 목록 = 발행 사이트에 나갈 목록. 첫 조회 때 서버가 채우므로 비어 있는 건 잠깐이다.
*/
export function useLocalGuide(): LocalGuideData {
const placeId = useBuilderStore((s) => s.placeId);
const [guide, setGuide] = useState<LocalGuideData>(() => (placeId && cache.get(placeId)) || EMPTY);
useEffect(() => {
if (!placeId) {
setGuide(EMPTY);
return;
}
const cached = cache.get(placeId);
if (cached) {
setGuide(cached);
return;
}
let alive = true;
const controller = new AbortController();
async function fetchGuide() {
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800';
const query = new URLSearchParams({place_id: placeId!});
const res = await fetch(`${baseUrl}/v1/local/guide?${query}`, {signal: controller.signal});
if (!res.ok) return;
const data = await res.json();
if (!data?.result?.success || !alive) return;
const next: LocalGuideData = {
foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival),
syncedAt: data.synced_at ?? undefined,
};
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면
// 수집이 끝나도 새로고침 전까지 계속 "준비 중"으로 보인다.
if (next.foods.length || next.spots.length || next.festivals.length) cache.set(placeId!, next);
setGuide(next);
} catch {
// 빈 목록 유지 — 화면에 에러를 띄우지 않는다.
}
}
void fetchGuide();
return () => {
alive = false;
controller.abort();
};
}, [placeId]);
return guide;
}