Compare commits

...

6 Commits

Author SHA1 Message Date
534122dccf [docs] devlog: 가짜 발행 수정 기록 — 코드가 f2dad65 에 섞여 들어갔다
같은 레포를 동시에 작업하던 다른 세션이 커밋하면서, 스테이지에 올려 둔
`PublishModal.tsx`·`Step3DataReview.tsx` 가 그쪽 커밋에 같이 담겼다. 코드는 남아 있지만
커밋 제목이 그 변경을 가리키지 않아 나중에 왜 그렇게 됐는지 찾을 길이 없다 — 기록만 세운다.
히스토리는 고치지 않는다. 다른 세션이 같은 브랜치에 계속 커밋 중이라 되감으면 그쪽이 깨진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-10 10:50:04 +09:00
1e0edeef8f [docs] deploy: 킹서버 DB 절을 마이그레이션 체계로 — 코드만 갈면 스키마가 안 따라온다
이 절은 아직 "`init.sql` 한 벌이 스키마 전부" 이고 "이미 있는 DB 에는 파일 하단의
ALTER 절만 손으로 돌려라" 라고 적혀 있었다. 그 방식은 이번 재편에서 없어졌다 —
`postgres-init/migrations/` + `scripts/migrate.py` 가 대신한다.

- 스키마 파일이 두 벌이라는 것과 둘 다 최신을 유지해야 하는 이유
- 배포 절차에 `migrate.py --dry-run` → `migrate.py` 를 넣는다. `postgres-init/` 은
  이미지에 굽지 않고 마운트하므로 코드 배포와 따로 돌릴 수 있다
- ★ 이번 배포(0005~0008)는 스키마를 통째로 편다. 안 돌리면 컨테이너는 정상으로 뜨고
  가게 등록·수집·발행만 죽는다 — 없는 표를 부르는 코드는 기동을 통과한다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 10:49:23 +09:00
b1386dd3ce [refactor] solution,nginx: 에디터와 발행본을 한 렌더러로 — 보는 그대로 나간다
에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):

  발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
  (gallery↔photos · location↔map · guide↔local)
  겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)

소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.

**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.

**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.

**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).

**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.

**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.

곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
  이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
  일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
  붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
  src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**

검증(Playwright, 같은 사업장·1024px):
  섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
  15개 전부 높이·글자 수·제목이 정확히 같다
  편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
  `/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:09:28 +09:00
387783b766 [fix] solution/backend: 옛 테이블 이름 잔재로 빌더가 통째로 안 돌던 것
웹빌더가 세 자리에서 연달아 죽었다 — 가게 등록 · 수집 시작 · 수집 완료. 전부 같은 뿌리다:
DB 구조 재편이 테이블 이름을 옮기면서 **참조 세 종류 중 일부만** 따라갔다.

- **생성자 12군데** (`place_links(...)` → `place_channels(...)`)
  import 와 `DBType()` 은 고쳤는데 생성자를 빠뜨렸다. 클래스가 없어도 import 는 통과하므로
  기동은 정상이고, 그 줄이 실제로 실행되는 순간에만 터진다.
- **raw SQL 12군데** (`job.jobs` → `jobs`)
  잡 큐만 raw SQL 이라 ORM 이름 변경에 안 딸려 왔다. 큐가 안 도니 수집·비전·소개문·빌드·
  지역데이터가 하나도 못 들어간다. 화면에는 "버튼만 안 먹는" 것으로 보였다.
- **같은 이름의 속성 5군데** (`source.place_facts` → `source.facts` 등)
  이름만 보고 일괄 치환해 테이블과 무관한 자리까지 바뀌었다. `RawSource` 는 수집기 결과
  객체지 테이블이 아니다.
- **뗀 표를 계속 부르던 5군데** (`ai_check_results`)
  한 번도 쓰지 않아 마이그레이션이 뗀 표다. 부르면 SEO 진단이 통째로 죽는다.

★ 하나씩 터질 때마다 고치다가 멈추고 정적 검사로 남은 것을 한 번에 셌다 — pyflakes 가 19건을
  짚었다. 이 종류는 import 도 타입검사도 안 잡는다. 테이블 이름을 옮긴 뒤에는
  `python -m pyflakes services/ crud/ router/ worker/ common/ | grep "undefined name"` 을 돌린다.

검증: 직접 수집 실행(스테이,머뭄) — 잡 DONE · 재시도 0 · fact 2 · 사진 10 · 채널 2 저장.
정의 안 된 이름 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:51 +09:00
c4af53613e [feat] solution,postgres-init: 지역 이야기를 서버가 채운다 · 공용과 개인화를 이름으로 가른다
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0.

**생성**
- Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다
  (같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이
  기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다).
- 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라
  우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에
  한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다.
- 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던
  그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋).
- 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서
  사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다.

**자리 가르기**
    area_*        = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로.
    site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집).
- `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 —
  같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다.
- 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지
  배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은
  읽는 쪽이 갖는다.
- ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중
  **첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면
  UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다.
- 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절.

검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에
songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:31 +09:00
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
220 changed files with 15331 additions and 5484 deletions

View File

@ -142,7 +142,9 @@ docker compose logs -f solution-worker
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
스키마는 `postgres-init/init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/`
(이미 만들어진 DB 보정)다. **둘 다 고친다** — init.sql 만 고치면 서버 DB 에 반영되지 않고,
마이그레이션만 쓰면 새로 세운 DB 에 그 변경이 없다. 적용: `scripts/migrate.py`
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
`npm run dev:frontend` / `dev:admin` / `dev:site`
- 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`
@ -260,7 +262,9 @@ docker compose logs -f solution-worker
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
스키마는 `postgres-init/init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/`
(이미 만들어진 DB 보정)다. **둘 다 고친다** — init.sql 만 고치면 서버 DB 에 반영되지 않고,
마이그레이션만 쓰면 새로 세운 DB 에 그 변경이 없다. 적용: `scripts/migrate.py`
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
`npm run dev:frontend` / `dev:admin` / `dev:site`
- 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`

View File

@ -9,9 +9,13 @@ import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner';
type Status = 1 | 2 | 3;
// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로
// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather).
type ContentType = 2 | 3 | 4;
type LocalContent = {
id: string;
contentType: ContentType;
title: string;
region: string;
period: string;
@ -24,10 +28,15 @@ type LocalContent = {
};
const STATUS_LABEL: Record<Status, string> = {1: '검수 대기', 2: '발행됨', 3: '종료됨'};
const TYPE_LABEL: Record<ContentType, string> = {2: '축제', 3: '관광지', 4: '맛집'};
const TYPE_BADGE_VARIANT: Record<ContentType, 'accent' | 'default' | 'outline'> = {
2: 'accent', 3: 'default', 4: 'outline',
};
type ApiContent = {
local_content_id: string;
region_code: string;
content_type: ContentType;
external_id?: string;
title?: string;
body: Record<string, unknown>;
@ -43,14 +52,17 @@ function formatDate(value: unknown) {
}
function toContent(item: ApiContent): LocalContent {
// 기간은 축제만 있다(eventstartdate/eventenddate) — 관광지·맛집은 상시 정보라 비어 있는 게 정상이다.
const start = formatDate(item.body.eventstartdate);
const end = formatDate(item.body.eventenddate);
const period = [start, end].filter(Boolean).join(' ~ ') || (item.content_type === 2 ? '기간 미정' : '상시');
return {
id: item.local_content_id,
contentType: item.content_type,
title: item.title || '제목 없음',
region: String(item.body.addr1 || item.region_code),
period: [start, end].filter(Boolean).join(' ~ ') || '기간 미정',
source: `공공데이터포털 전국문화축제표준데이터 · ${item.external_id ?? '-'}`,
period,
source: `한국관광공사 TourAPI · ${item.external_id ?? '-'}`,
status: item.status,
selected: false,
displayStart: item.display_start_at,
@ -95,15 +107,24 @@ export function LocalContentPage() {
} catch { toast.error('발행하지 못했습니다.'); }
};
const sync = async () => {
const regionCode = window.prompt('내부 지역 코드(예: gunsan)를 입력하세요.', 'gunsan')?.trim();
if (!regionCode) return;
// ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
// 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다.
const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim();
if (!placeId) return;
setSyncing(true);
try {
const res = await customFetch<{result?: {success?: boolean}; msg?: string; collected?: number; skipped?: number}>({
url: '/v1/admin/local-content/sync-festivals', method: 'POST', data: {region_code: regionCode},
});
const res = await customFetch<{
result?: {success?: boolean}; msg?: string;
festivals?: number; attractions?: number; restaurants?: number; changed?: boolean;
}>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'});
if (res.result?.success === false) throw new Error(res.msg);
toast.success(`${res.collected ?? 0}건 수집 · ${res.skipped ?? 0}건 중복 제외`);
// ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다.
const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`;
if (!res.changed) {
toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`);
} else {
toast.success(`${summary} 반영 — 다음 빌드부터 발행본에 실립니다.`);
}
await load();
} catch (error) {
toast.error(error instanceof Error ? error.message : '공공데이터 수집에 실패했습니다.');
@ -138,7 +159,7 @@ export function LocalContentPage() {
return (
<PageContainer
title="지역 콘텐츠"
description="공공데이터에서 지역 축제를 가져와 검수한 뒤 사장님에게 발행합니다."
description="한국관광공사 TourAPI 에서 지역 축제·관광지·맛집을 가져와 자동 발행합니다. 필요하면 여기서 수정하거나 발행을 종료할 수 있습니다."
actions={<Button onClick={sync} disabled={syncing}><Download className="size-4" />{syncing ? '수집 중…' : '공공데이터 수집'}</Button>}
>
<div className="mb-4 grid gap-3 sm:grid-cols-3">
@ -174,6 +195,7 @@ export function LocalContentPage() {
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold">{item.title}</h2>
<Badge variant={TYPE_BADGE_VARIANT[item.contentType]}>{TYPE_LABEL[item.contentType]}</Badge>
<Badge variant={item.status === 2 ? 'success' : 'warning'}>{STATUS_LABEL[item.status]}</Badge>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">

View File

@ -49,6 +49,9 @@ services:
SCHEDULER_ENABLED: "1"
volumes:
- ./solution/site/payloads:/app/out/payloads
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
- ./postgres-init:/app/postgres-init:ro
ports:
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
extra_hosts:

View File

@ -87,7 +87,7 @@
| 레이어 구조 | 원본 그대로 — `router` → `service` → `crud`, 람다 DB 실행, `Req_*`/`Res_*` 프로토콜, `RemoveNoneResponse` | "기존 컨벤션을 그대로 따른다" |
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
| DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 |
| 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 |
| 마이그레이션 | Alembic 안 씀. `init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/NNNN_*.sql`(기존 DB 보정), 적용기 `scripts/migrate.py` | 2026-08-31 에 누적 ALTER 를 없애며 "운영 DB 가 생기는 순간 다시 필요해진다" 고 적어 뒀다. **2026-09-09 그 순간이 왔다** — init.sql 은 DB 를 처음 만들 때만 도는데 서버·로컬에 이미 데이터가 있어서, `local.place_contents` 테이블과 `places.external_category` 컬럼이 실제 DB 에만 빠져 있었다. TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고 화면에는 "그냥 안 나오는 것"으로만 보였다. Alembic 을 안 쓰는 이유는 그대로다 — ORM·init.sql 두 곳에 스키마가 있고 `test_schema_ddl.py` 가 대조하는 구조라, 세 번째 정의를 더하면 어긋날 자리가 하나 더 생긴다 |
| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 |
| 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 |
| `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 |
@ -195,3 +195,50 @@
**업로드·저장 경로가 없다.** 이미지 재게시 권리(1-2)가 미결이라 Azure Blob 클라이언트를
일부러 아직 이식하지 않았다.
- 카카오 REST API 키 / TourAPI 키가 사내 어디에도 없다. 발급해서 `.env` 에 채워야 실제 연동이 돈다.
## 6. 지역 이야기 생성 (2026-09-09)
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 같은 템플릿을
골라도 그 자리가 비었다. 그래서 서버가 채운다.
### 6-1. 키는 업장이 아니라 지역이다
이 다섯은 업장의 사실이 아니라 **도시의 사실**이다. 군산 이야기는 군산 숙소가 같이 쓴다.
`place_id` 를 키로 잡으면 같은 지역에 숙소 50곳이 들어올 때 같은 곡 목록을 50번 만든다 —
`area_contents` 가 `region_code` 를 키로 두는 것과 같은 이유이고, 그 표를 그대로 쓴다.
→ **사이트별 `sections[].data` 로 복사하지 않는다.** payload 에서는 `local.story` 로 따로 싣고,
화면이 **읽는 순간에만** 사장님이 붙여넣은 것과 한 배열로 잇는다(`site/src/lib/derive.ts` `sectionItems`).
복사해 두면 지역 하나를 고칠 때 사이트 수만큼 고쳐야 한다.
### 6-2. 검수 게이트를 두지 않는다
생성분은 `PUBLISHED` 로 저장해 **바로 발행본에 나간다.** 공공데이터(맛집·관광지)를 검수 없이
싣는 2026-09-03 결정과 같은 규약이다.
- 대신 항목마다 `verified`(확인 · 확인필요)와 `source`(열리는 URL)가 실린다. 출처가 없는 항목은
저장 단계에서 버리고, 항목 자신의 출처가 없어 검색 출처로 때운 항목은 `확인` 이라고 우겨도
`확인필요` 로 내린다(`grounding/story.py`).
- 틀린 항목은 **사장님이 에디터에서 뺀다.** 별도 운영자 검수 화면을 만들지 않는다.
이건 "미검증 값 노출 금지" 에 STORY 만 예외를 두는 것이다. 근거: 이 값들은 fact 가 아니라
공적 지식이고, 화면이 확신도와 출처를 함께 밝히며, 틀려도 예약·요금처럼 손님이 손해를 보는
종류가 아니다. **fact·사진·FAQ 에는 이 예외를 넓히지 않는다.**
### 6-3. 프롬프트는 한 벌이다
사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와 서버가 도는 프롬프트가 같아야 한다.
단일 출처는 `solution/shared/src/lib/section-prompts.ts` 이고,
`npm run export:prompts` 가 `solution/backend/services/prompts/section_prompts.json` 으로 뽑는다(커밋).
백엔드 컨테이너에 node 를 넣지 않으려고 산출물을 커밋한다 — `scripts/export_openapi.py` 의 반대 방향이다.
### 6-4. Perplexity 한 곳이다
이 값들은 **출처가 붙어야** 쓸 수 있다. Gemini 는 검색을 안 해서 URL 을 지어내고,
Perplexity 는 실제로 읽은 `search_results` 를 함께 준다. 구조는 프롬프트의 `[스키마]` 블록이
잡고 파이썬은 항목 모양을 다시 적지 않는다 — 적으면 프론트가 필드를 하나 늘린 날 서버가
그걸 조용히 떨어뜨린다.
**종류당 1회, 지역당 1세트.** 다섯을 한 프롬프트에 넣으면 출력이 잘리고, 한 종이 실패하면
전부 다시 돌고, 검색 출처가 어느 항목 것인지 섞인다. 항목당 1회는 반대로 낭비다.

View File

@ -5,6 +5,29 @@
---
## 2026-09-09 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈)
**무슨 일** — 이 다섯은 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 이제 지역 단위로 한 번 생성해 같은 지역 사이트가 나눠 쓴다.
- **키는 지역이다.** `area_contents`(region_code × kind) 에 종류당 한 행, `body.items` 에 항목들.
사이트별 `sections[].data` 로 복사하지 않는다 — 화면이 읽는 순간에만 사장님이 붙여넣은 것과
한 배열로 잇는다(`site/src/lib/derive.ts` `sectionItems`).
- **Perplexity 종류당 1회.** 출처(`search_results`)가 함께 오는 유일한 통로다. 항목에 출처가
없으면 버리고, 검색 출처로 때운 항목은 `확인` 이라 우겨도 `확인필요` 로 내린다.
- **프롬프트는 한 벌.** 사장님이 [콘텐츠] 탭에서 복사해 가던 그 문장을 그대로 쓴다 —
`shared/lib/section-prompts.ts` 가 단일 출처, `npm run export:prompts` 로 백엔드용 JSON 을 뽑는다.
- **트리거는 cache-aside.** 에디터 캔버스가 주변 정보를 처음 부를 때 지역 이야기 생성 잡
(`JobType.LOCAL_SYNC`, 선언만 있고 미배선이던 것)을 하나 넣는다. `dedupe_key = story:{region_code}`
라 같은 지역 숙소 50곳이 동시에 열어도 잡은 하나다.
- 검수 게이트는 두지 않는다 — 결론과 근거는 [DECISIONS.md 6절](DECISIONS.md).
**검증** — `tsc -b` 통과 · 지역 이야기 단위 테스트 12건 통과.
⚠️ 이 레포의 pytest 전체는 이 브랜치 이전부터 **로컬 Postgres 인증 실패로 569건 전부 error** 다
(`password authentication failed for user "postgres"`). 새 테스트는 DB 를 안 쓰는데 세션 픽스처가
DB 를 먼저 세워서 함께 막힌다 — 환경 문제이고 별건이다.
## 2026-09-09 — 예약 안내 안에 날짜·시간 목업을 넣는다 (연동 없음)
**무슨 일** — 예약 흐름을 화면으로 보기 위해 `StayBookingDemo` 를 예약 안내 섹션 안에 넣었다.
@ -54,6 +77,44 @@
JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후
`/s/<slug>` 에서 데모 껍데기와 안내 문구 확인.
---
## 2026-09-08 — 가짜 발행을 없앴다 — 굽지도 않고 [사이트 열기] 를 그렸다
**무슨 일**
발행 모달에서 [발행하기] 를 누르면 "발행 준비가 끝났습니다" 토스트가 뜨고 [사이트 열기]
버튼이 생겼다. **서버를 한 번도 안 불렀고, 그 주소는 404 다.** 목록에도 안 생긴다.
사장님은 발행됐다고 믿는다.
**왜**
`PublishModal.handlePublish` 가 `publisher.isLive`(= placeId + 토큰)가 거짓이면 서버 호출을
건너뛰고 `setPublishedUrl(url)` 로 스토어에 주소를 박았다. 그러면 `isDone` 이 참이 되어 완료
화면이 그려진다. 데모 경로를 위해 둔 분기인데 **로그인한 사장님도 이 길로 온다** — 3단계의
[수집 없이 다음 단계로](직접 입력)로 나가면 서버에 사업장이 없는 채 에디터까지 가고,
거기서 로그인해도 `placeId` 는 여전히 없다.
**고친 것**
- 가짜 분기 삭제. `isDone` 은 `state.phase === 'published'` 하나로 줄였다 — 굽지 않은 주소에
[사이트 열기] 가 붙던 자리가 여기다
- 발행 불가 사유를 `PublishBlocker`(`signin` · `place`)로 갈라 모달 안에서 말한다.
blocker 가 있으면 주소칸·점검·발행 버튼을 아예 그리지 않는다
- 비로그인: `/login` 으로 튕기지 않고 모달 안에 로그인 폼을 둔다 — 빌더 스토어는 비영속이라
튕기면 만들던 게 날아간다(`EditorSignInGate` 와 같은 이유)
- 로그인 O + 사업장 X: 이유를 말하고 [내 가게 확인하러 가기] → `/builder?step=search`.
여기서 사업장을 몰래 만들지 않는다 — 생성·검증 순서는 `ensureServerPlace` 한 곳이 소유한다
- 3단계 버튼을 [발행 없이 화면만 둘러보기] 로 바꾸고 "이 길로 가면 발행이 안 된다" 를 붙였다.
버튼은 남긴다 — 검증을 못 통과한 사람이 화면을 구경할 길까지 막을 이유는 없다
**검증** — 프론트 tsc+eslint 통과. 백엔드가 같은 상황을 어떻게 거절하는지도 확인했다:
검증 안 된 사업장으로 발행하면 `PLACE_NOT_VERIFIED` 다. 서버는 이렇게 분명히 막는데
프론트만 서버를 안 부르고 성공을 말하고 있었다.
⚠️ 이 변경의 **코드는 f2dad65 에 섞여 들어갔다** — 같은 레포를 동시에 작업하던 다른 세션이
커밋할 때 스테이지에 올려 둔 `PublishModal.tsx`·`Step3DataReview.tsx` 를 같이 담았다.
그 커밋 제목은 발행본 목록 주소 얘기라 이 변경을 가리키지 않는다. 기록은 여기에 남긴다.
---
## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다
**무슨 일**

View File

@ -119,14 +119,41 @@ cd ~/data2/o2o-site-AEO
호스트에 PostgreSQL 15(pgvecto)가 `king_postgres_container` 로 떠 있고 `5432` 가 호스트에 열려 있다.
컴포즈가 `DB_HOST` 기본값을 `host.docker.internal` 로 두고 `extra_hosts: host-gateway` 를
붙여 두었으므로 **컴포즈를 고치지 않고 그대로 닿는다.** DB 는 만들어야 한다 —
`postgres-init/init-data/init.sql` 한 벌이 스키마 전부다.
붙여 두었으므로 **컴포즈를 고치지 않고 그대로 닿는다.**
★ **`init.sql` 은 DB 를 처음 만들 때만 돈다.** 이미 있는 DB 에는 파일 하단의 "기존 DB 보정(ALTER)"
절만 손으로 돌려야 새 컬럼이 들어간다. 빠뜨리면 **HTTP 는 200 인데 기능만 죽는다** —
스키마 파일은 **두 벌**이고 둘 다 최신을 유지한다 —
`postgres-init/init-data/init.sql` 은 **새 DB 를 세우는 전체 DDL**,
`postgres-init/migrations/NNNN_*.sql` 은 **이미 데이터가 든 DB** 를 거기까지 끌어올린다.
한쪽만 고치면 새로 세운 DB 와 서버 DB 가 조용히 갈라진다.
★ **`init.sql` 은 DB 를 처음 만들 때만 돈다**(postgres 이미지의 초기화 훅). 파일에 컬럼을
더해도 서버 DB 에는 들어가지 않는다. 빠뜨리면 **HTTP 는 200 인데 기능만 죽는다** —
실측(2026-09-03): `users.provider` 없음 → 로그인 전부 실패, `sites.thumbnail_url` 없음 →
쇼케이스 전부 실패. 로그를 봐야 보인다. 컬럼을 `CREATE TABLE` 에만 추가하고 ALTER 절에
안 적으면 **새 DB 는 되고 기존 DB 만 조용히 깨진다.**
쇼케이스 전부 실패. 실측(2026-09-09): `local.place_contents` 없음 → TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집 0건. 셋 다 화면이 아니라 로그를 봐야 보인다.
### 배포할 때 — 코드만 갈면 스키마는 안 따라온다
`postgres-init/` 은 이미지에 굽지 않고 백엔드 컨테이너에 마운트한다(`docker-compose.yml`).
코드 배포와 별개로 돌릴 수 있어야 하기 때문이다.
```bash
cd ~/data2/o2o-site-AEO
./deploy.sh api
docker compose exec solution-backend python scripts/migrate.py --dry-run # 뭐가 돌지 먼저 본다
docker compose exec solution-backend python scripts/migrate.py
```
적용 기록은 `public.schema_migrations` 에 남고 이미 있는 번호는 건너뛴다. 파일은 재실행
안전하게(`IF NOT EXISTS`) 쓰므로 손으로 한 번 더 돌려도 된다.
규칙은 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
★ **2026-09-10 배포는 스키마가 통째로 바뀐다**(`0005`~`0008`). 도메인별 스키마
(`company` · `place` · `fact` · `local` · `site` · `job`)를 걷어내 `public` 한 벌로 폈고
표 이름도 옮겼다 — `place_links` → `place_channels`, `job.jobs` → `jobs`, 공용 콘텐츠는
`area_contents` 한 벌, 개인화는 `site_sections` 로 모았다.
**마이그레이션을 안 돌리면 컨테이너는 정상으로 뜨고 가게 등록 · 수집 · 발행만 죽는다** —
없는 표를 부르는 코드는 import 도 기동도 통과하고 그 줄이 실행되는 순간에만 터진다.
## 공개 주소 — `https://web4ai.o2osolution.ai` (2026-09-03 기준)

View File

@ -77,6 +77,17 @@ server {
}
# 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
# 빌더 미리보기 셸. **발행본 번들**을 띄우는 CSR 한 장이다
# (`solution/site/scripts/prerender.writePreviewShell`).
# ★ 빌더 SPA(`location /`)로 떨어지면 안 된다 — 거기로 가면 미리보기 안에 빌더가 또 뜬다.
# ★ iframe 으로 여는 이유는 뷰포트다. 빌더 안에 직접 그리면 미디어 쿼리가 창 폭을 봐서
# 그리드 컬럼 수가 발행본과 달라진다(실측 89% 픽셀 차이 — SitePreview 머리주석).
location = /preview {
root /srv/sites;
try_files /preview/index.html =404;
add_header Cache-Control "no-store" always;
}
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;

View File

@ -21,7 +21,8 @@
"prerender": "npm run prerender -w @o2o/site",
"lint": "npm run lint -w @o2o/frontend && npm run lint -w @o2o/admin && npm run lint -w @o2o/site",
"orval": "npm run orval -w @o2o/frontend",
"clean": "rm -rf solution/frontend/dist admin/frontend/dist solution/site/dist solution/site/.ssr-dist node_modules/.vite"
"clean": "rm -rf solution/frontend/dist admin/frontend/dist solution/site/dist solution/site/.ssr-dist node_modules/.vite",
"export:prompts": "node solution/shared/scripts/export-prompts.mjs"
},
"engines": {
"node": ">=20"

View File

@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS place.places (
latitude NUMERIC(10,7) NULL, -- 위도
longitude NUMERIC(10,7) NULL, -- 경도
region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드 — ★ 지역정보 캐시 키
external_category VARCHAR(200) NULL, -- 외부 장소 DB 분류 원문("음식점 > 한식 > 육류" / "펜션") — 주변 맛집 경쟁업소 제외 기준(폴백)
verified_at TIMESTAMPTZ NULL, -- ★ 동일 업소 검증 통과 시각. NULL = 수집·발행 금지
verified_by uuid NULL, -- 검증자(company.users.user_id)
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각 — 개별 재빌드 대상 판별용
@ -211,6 +212,73 @@ CREATE TABLE IF NOT EXISTS local.local_contents (
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 업장 반경의 주변 정보(맛집·관광지·축제·여행코스). ★ 키는 place_id — local_contents(행정구역 캐시)와 다르다.
-- 빌드 때마다 TourAPI locationBasedList2 로 갱신. 응답에서 사라진 행은 소프트 삭제, hidden 은 유지.
CREATE TABLE IF NOT EXISTS local.place_contents (
place_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 주변 정보 식별자(PK)
place_id uuid NOT NULL, -- 사업장(place.places.place_id)
content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집 5=여행코스
external_id VARCHAR(100) NOT NULL, -- TourAPI contentid
title VARCHAR(300) NOT NULL,
body JSONB NOT NULL, -- 정규화한 TourAPI 항목(좌표·주소·사진·기간)
distance_m INTEGER NOT NULL, -- 업장 좌표에서의 거리(m). 정렬 기준
has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음)
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 축제·관광지·맛집 **실체**. 전국 공용 — 같은 장소를 업장마다 복제하지 않는다.
-- ★ place_contents 는 키가 (place_id, …) 라 업장마다 TourAPI 응답을 통째로 복제했다.
-- 실측(2026-09-09): 업장 한 곳에 144행. 열 곳이면 같은 축제가 열 벌이다.
-- 실체는 여기 한 행, 업장별로 다른 것(거리·숨김)만 place_spots 에 남긴다.
CREATE TABLE IF NOT EXISTS local.spots (
spot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 장소 식별자(PK)
source SMALLINT NOT NULL, -- 출처(LocalSource): 2=TourAPI
external_id VARCHAR(100) NOT NULL, -- TourAPI contentid
content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집
title VARCHAR(300) NOT NULL,
body JSONB NOT NULL, -- 정규화한 원본(주소·사진·기간·분류)
latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 — 거리 계산이 읽는다
longitude NUMERIC(10,7) NULL,
region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드(지역 단위 조회)
has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 업장 ↔ 주변 장소. 업장별로 다른 것은 거리와 숨김뿐이다.
CREATE TABLE IF NOT EXISTS local.place_spots (
place_id uuid NOT NULL, -- 사업장(place.places.place_id)
spot_id uuid NOT NULL, -- 장소(local.spots.spot_id)
distance_m INTEGER NOT NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (place_id, spot_id)
);
-- 지역 이야기(가요·인물·연표·엽서·퀴즈). ★ 업장이 아니라 **지역**의 것이다 —
-- 군산 이야기는 군산 숙소가 같이 쓴다. 검수 전에는 발행에 나가지 않는다(status).
CREATE TABLE IF NOT EXISTS local.region_stories (
region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 이야기 식별자(PK)
region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드
kind VARCHAR(50) NOT NULL, -- 'songs' 'people' 'chronicle' 'postcard' 'quiz'
data JSONB NOT NULL,
source_type SMALLINT NOT NULL DEFAULT 4, -- 출처(SourceType): 4=llm 1=운영자
status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS local.routes (
route_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 경로 식별자(PK)
place_id uuid NOT NULL, -- 사업장(place.places.place_id)
@ -265,6 +333,24 @@ CREATE TABLE IF NOT EXISTS site.sites (
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다.
-- sites.theme 은 색·서체·섹션 순서/on-off 만 갖고, 내용은 여기로 나온다.
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 콘텐츠가 39,645 B(94%)였다.
-- 크기가 아니라 쓰기 단위가 문제였다 — 영상 주소 하나를 고쳐도 42 KB 를 다시 썼다.
CREATE TABLE IF NOT EXISTS site.site_contents (
site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 콘텐츠 식별자(PK)
site_id uuid NOT NULL, -- 사이트(site.sites.site_id)
section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' …
data JSONB NOT NULL, -- 그 섹션의 항목 배열(shared 의 XxxItem[])
source_type SMALLINT NOT NULL DEFAULT 1, -- 출처(SourceType): 1=owner 2=api 4=llm
shared_ref uuid NULL, -- ★ 공유 원본(local.region_stories 등)을 가리킬 때. 값을 복제하지 않는다
status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus): 3·4 만 노출
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS site.site_versions (
site_version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 버전 식별자(PK)
site_id uuid NOT NULL, -- 사이트(site.sites.site_id)
@ -379,13 +465,24 @@ CREATE INDEX IF NOT EXISTS idx_facts_publishable ON fact.facts (place_id, status
-- local
CREATE INDEX IF NOT EXISTS idx_local_contents_region ON local.local_contents (region_code);
CREATE INDEX IF NOT EXISTS idx_routes_place ON local.routes (place_id);
CREATE INDEX IF NOT EXISTS idx_place_contents_place ON local.place_contents (place_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_contents_keyed ON local.place_contents (place_id, content_type, external_id) WHERE deleted = false;
CREATE INDEX IF NOT EXISTS idx_nearby_links_place ON local.nearby_links (place_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_spots_external ON local.spots (source, external_id) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_spots_region ON local.spots (region_code, content_type);
CREATE INDEX IF NOT EXISTS idx_place_spots_place ON local.place_spots (place_id) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind ON local.region_stories (region_code, kind) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_site_contents_site ON site.site_contents (site_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section ON site.site_contents (site_id, section_id) WHERE deleted = FALSE;
-- 지역 캐시 중복 방지. external_id 가 있는 항목(축제·관광지·맛집)과 없는 항목(날씨)을 나눠 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_keyed ON local.local_contents (region_code, content_type, external_id)
WHERE deleted = FALSE AND external_id IS NOT NULL;
-- ★ kind 가 있는 행(지역 이야기 다섯 종)은 이 인덱스에서 뺀다 — 그 다섯은 external_id 가 없어
-- (region_code, content_type) 하나를 두고 서로 부딪친다. 이야기의 유일성은
-- uq_local_contents_kind (region_code, kind) 가 책임진다(migrations/0004·0007).
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_single ON local.local_contents (region_code, content_type)
WHERE deleted = FALSE AND external_id IS NULL;
WHERE deleted = FALSE AND external_id IS NULL AND kind IS NULL;
-- site
CREATE INDEX IF NOT EXISTS idx_site_versions_site ON site.site_versions (site_id);

View File

@ -0,0 +1,30 @@
-- 0001 · 업장 반경 주변정보 테이블 + 외부 분류 컬럼
--
-- init.sql 에는 둘 다 있는데 그 파일은 DB 를 처음 만들 때만 돈다. 이미 만들어진 DB
-- (로컬·서버)에는 없어서, TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없었다 —
-- 축제·맛집이 0건이었고 화면에는 "그냥 안 나오는 것"으로만 보였다(실측 2026-09-09).
CREATE TABLE IF NOT EXISTS local.place_contents (
place_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
content_type SMALLINT NOT NULL, -- LocalContentType: 2=축제 3=관광지 4=맛집 5=여행코스
external_id VARCHAR(100) NOT NULL, -- TourAPI contentid
title VARCHAR(300) NOT NULL,
body JSONB NOT NULL, -- 정규화한 TourAPI 항목(좌표·주소·사진·기간)
distance_m INTEGER NOT NULL, -- 업장 좌표에서의 거리(m). 정렬 기준
has_image BOOLEAN NOT NULL DEFAULT FALSE,
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음)
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_place_contents_place ON local.place_contents (place_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_contents_keyed
ON local.place_contents (place_id, content_type, external_id) WHERE deleted = FALSE;
-- 주변 맛집에서 경쟁 업소를 빼는 기준(폴백). 이 컬럼이 없으면 places 조회 자체가
-- UndefinedColumnError 로 죽어 빌드가 "사업장을 찾을 수 없다"로 끝난다.
ALTER TABLE place.places ADD COLUMN IF NOT EXISTS external_category VARCHAR(200) NULL;

View File

@ -0,0 +1,76 @@
-- 0002 · 주변 정보를 **실체와 관계로** 가른다
--
-- 지금 `local.place_contents` 는 키가 (place_id, content_type, external_id) 라서
-- 업장마다 TourAPI 응답을 통째로 복제한다. 축제 그 자체(실체)와 "우리 업장에서 850m"
-- (관계)가 한 행에 섞여 있어서 그렇다.
-- 실측(2026-09-09, 조이모텔): 한 곳에 144행. 성남 중원구에 모텔이 10곳 들어오면
-- 같은 축제·같은 맛집이 10벌이 된다 — TourAPI 가 준 값은 글자 하나까지 같고 거리만 다르다.
--
-- 실체는 `spots` 에 external_id 로 한 행만 두고, 업장별로 다른 것(거리·숨김)만
-- `place_spots` 에 남긴다. 원본을 한 번 갱신하면 그 장소를 참조하는 사이트 전부에 반영된다.
-- 실체 — 축제·관광지·맛집. 전국 공용.
CREATE TABLE IF NOT EXISTS local.spots (
spot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source SMALLINT NOT NULL, -- LocalSource: 2=TourAPI
external_id VARCHAR(100) NOT NULL, -- TourAPI contentid
content_type SMALLINT NOT NULL, -- LocalContentType: 2=축제 3=관광지 4=맛집
title VARCHAR(300) NOT NULL,
body JSONB NOT NULL, -- 정규화한 원본(주소·사진·기간·분류)
latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 둔다 — 거리 계산이 이걸 읽는다
longitude NUMERIC(10,7) NULL,
region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드(지역 단위 조회용)
has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- ★ 같은 장소를 두 번 담지 않는 유일성. 이 인덱스가 곧 "공유"의 근거다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_spots_external
ON local.spots (source, external_id) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_spots_region ON local.spots (region_code, content_type);
-- 관계 — 이 업장에서 얼마나 먼가. 업장별로 다른 건 이것뿐이다.
CREATE TABLE IF NOT EXISTS local.place_spots (
place_id uuid NOT NULL, -- place.places.place_id
spot_id uuid NOT NULL, -- local.spots.spot_id
distance_m INTEGER NOT NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 사장님·운영자가 뺀 것. 재수집이 덮어쓰지 않는다
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (place_id, spot_id)
);
CREATE INDEX IF NOT EXISTS idx_place_spots_place ON local.place_spots (place_id) WHERE deleted = FALSE;
-- 이미 place_contents 에 쌓인 것을 옮긴다. 같은 external_id 는 한 행으로 접힌다.
-- ★ ON CONFLICT 대신 NOT EXISTS 로 쓴다 — 부분 유니크 인덱스(WHERE deleted=FALSE)는
-- ON CONFLICT 의 추론 대상이 되지만, 조건까지 적어야 해서 읽기 어렵다.
INSERT INTO local.spots (source, external_id, content_type, title, body,
latitude, longitude, has_image, display_end_at, collected_at)
SELECT DISTINCT ON (pc.external_id)
2, pc.external_id, pc.content_type, pc.title, pc.body,
NULLIF(pc.body->>'mapy', '')::NUMERIC,
NULLIF(pc.body->>'mapx', '')::NUMERIC,
pc.has_image, pc.display_end_at, pc.collected_at
FROM local.place_contents pc
WHERE pc.deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM local.spots s
WHERE s.source = 2 AND s.external_id = pc.external_id AND s.deleted = FALSE
)
ORDER BY pc.external_id, pc.collected_at DESC;
INSERT INTO local.place_spots (place_id, spot_id, distance_m, hidden)
SELECT pc.place_id, s.spot_id, pc.distance_m, pc.hidden
FROM local.place_contents pc
JOIN local.spots s ON s.source = 2 AND s.external_id = pc.external_id AND s.deleted = FALSE
WHERE pc.deleted = FALSE
ON CONFLICT (place_id, spot_id) DO NOTHING;
-- ★ place_contents 는 지우지 않는다. 읽는 쪽이 새 테이블로 다 옮겨간 뒤에 별도 번호로 뗀다 —
-- 한 번에 갈아엎으면 되돌릴 자리가 없다.

View File

@ -0,0 +1,75 @@
-- 0003 · 섹션 콘텐츠를 `sites.theme` 에서 꺼내 행으로
--
-- 지금은 색·서체(디자인)와 섹션별 콘텐츠가 `site.sites.theme` JSONB 한 칸에 같이 있다.
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고
-- 나머지 41,500 B 가 섹션이다. 그중 콘텐츠(sections[].data)만 39,645 B — 94%.
-- 가장 큰 섹션 하나(itinerary)가 17,990 B 로, 디자인 전체의 28 배다.
--
-- 크기가 문제인 게 아니다(JSONB 는 1GB 까지 든다). 문제는 **쓰기 단위**다:
-- · 사장님이 영상 주소 하나(592 B)를 고쳐도 42 KB 를 통째로 다시 쓴다
-- · 같은 사이트를 둘이 만지면 나중 쓰기가 앞을 통째로 덮는다
-- · 항목마다 "누가 넣었나 · 확인됐나"를 물을 자리가 없다(facts 는 status 를 갖는다)
-- · 검증이 없어 렌더러에 존재하지도 않는 섹션이 남는다
-- (실측: 조이모텔에 course·schedule — 켤 수는 있는데 화면엔 아무 일도 안 일어난다)
--
-- ★ 이 테이블이 JSON import/export 의 단위다. 사이트 하나 = 이 행 묶음 + theme(디자인).
CREATE TABLE IF NOT EXISTS site.site_contents (
site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid NOT NULL, -- site.sites.site_id
section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' …
data JSONB NOT NULL, -- 그 섹션의 항목 배열(shared 의 XxxItem[] 계약)
source_type SMALLINT NOT NULL DEFAULT 1, -- SourceType: 1=owner 2=api 3=crawl 4=llm
-- ★ 공유 콘텐츠는 값을 복제하지 않고 **id 로 가리킨다**(축제·지역 이야기).
-- 가리키는 동안 data 는 비어 있을 수 있다 — 발행할 때 원본을 펼쳐 payload 에 싣는다.
shared_ref uuid NULL,
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus 와 같은 축: 3=verified 4=corrected 만 노출
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 한 사이트의 한 섹션은 한 행이다. 순서·on/off 는 theme 이 갖는다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section
ON site.site_contents (site_id, section_id) WHERE deleted = FALSE;
-- 지역 이야기 — 가요·인물·연표·엽서·퀴즈는 **업장이 아니라 지역의 것**이다.
-- 군산 이야기는 군산 숙소가 같이 쓴다. 지금은 만들 자리가 아예 없어서
-- 시안(/s/stay)에는 사람이 3만 자를 손으로 넣었다.
CREATE TABLE IF NOT EXISTS local.region_stories (
region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드
kind VARCHAR(50) NOT NULL, -- 'songs' 'people' 'chronicle' 'postcard' 'quiz'
data JSONB NOT NULL,
source_type SMALLINT NOT NULL DEFAULT 4, -- 4=llm 1=운영자
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 검수 전에는 안 나간다
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind
ON local.region_stories (region_code, kind) WHERE deleted = FALSE;
-- 이미 theme 에 박혀 있는 콘텐츠를 옮긴다.
-- ★ data 는 문자열로 저장돼 있다(서버가 파싱하지 않는 규약) — JSONB 로 되돌린다.
-- 깨진 JSON 은 옮기지 않는다. 원본은 theme 에 남으므로 잃지 않는다.
INSERT INTO site.site_contents (site_id, section_id, data, source_type, status, sort_order)
SELECT s.site_id,
sec->>'id',
(sec->>'data')::JSONB,
1, -- 사장님이 넣은 것으로 본다
3, -- 이미 발행에 쓰이던 값이라 verified
ordinality - 1
FROM site.sites s
CROSS JOIN LATERAL jsonb_array_elements(s.theme->'sections') WITH ORDINALITY AS t(sec, ordinality)
WHERE s.deleted = FALSE
AND s.theme ? 'sections'
AND sec->>'data' IS NOT NULL
AND sec->>'data' <> ''
AND jsonb_typeof((sec->>'data')::JSONB) IS NOT NULL
ON CONFLICT DO NOTHING;
-- ★ theme.sections[].data 는 지우지 않는다. 읽는 쪽(site_payload._theme)이 새 테이블을
-- 먼저 보고 없을 때만 theme 으로 떨어지게 한 뒤, 별도 번호로 뗀다.

View File

@ -0,0 +1,102 @@
-- 0004 · 공용 콘텐츠를 **한 테이블로** 되돌린다 (0002·0003 정정)
--
-- 0002 에서 `local.spots` 를, 0003 에서 `local.region_stories` 를 새로 팠다. 그게 틀렸다.
-- 풀어야 했던 문제는 "`place_contents` 가 place_id 키라 업장마다 복제된다" 하나였고,
-- 그건 **실체와 관계를 가르면** 끝나는 문제였다. 그런데 거기에 더해 테이블까지 새로 만들어서
-- 같은 성격의 공용 콘텐츠가 세 곳으로 갈라졌다 —
-- local_contents 에 이미 축제 60 · 관광지 12 · 맛집 12건이 들어 있었다(실측 2026-09-09).
-- `local_contents` 는 처음부터 content_type 으로 종류를 가르는 설계였다. 그걸 쓰면 됐다.
--
-- 그래서 공용 콘텐츠는 `local.local_contents` 한 벌로 모으고,
-- 업장과의 관계(거리·숨김)만 `local.place_contents` 에 남긴다. 관계까지 합치면 복제가 돌아온다.
-- ── 1. 공용 테이블에 부족했던 것 ──────────────────────────────────────────
-- 좌표: 거리 계산과 일정 조립이 읽는다. body 안에 두면 행마다 JSON 을 파싱해야 한다.
ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS latitude NUMERIC(10,7) NULL;
ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS longitude NUMERIC(10,7) NULL;
-- 종류를 문자열로도 받는다 — 지역 이야기(songs·people·chronicle·postcard·quiz)는
-- SMALLINT 코드를 새로 발급하기보다 이름 그대로가 읽힌다. 장소류는 NULL.
ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS kind VARCHAR(50) NULL;
-- ★ region_code 를 nullable 로 푼다.
-- 축제·관광지·맛집은 **전국 공용**이다 — 같은 축제가 시군구마다 한 행씩 생기면
-- "한 벌"이 아니다. 지역은 조회 편의로 채우되 유일성의 근거는 external_id 다.
ALTER TABLE local.local_contents ALTER COLUMN region_code DROP NOT NULL;
-- 옛 유일성: (region_code, content_type, external_id) — 같은 축제를 지역 수만큼 허용한다.
DROP INDEX IF EXISTS local.uq_local_contents_keyed;
-- ★ 그 인덱스가 허용해 온 중복을 먼저 접는다. 실측(2026-09-09): 축제 60행이 지역 2곳에
-- 걸쳐 있었다 — 같은 행사가 시군구마다 한 행씩이다. 가장 최근에 수집한 것만 남긴다.
-- (접지 않고 유일 인덱스를 걸면 "could not create unique index" 로 통째로 실패한다.)
UPDATE local.local_contents lc
SET deleted = TRUE, updated_at = now()
WHERE lc.deleted = FALSE
AND lc.external_id IS NOT NULL
AND lc.local_content_id NOT IN (
SELECT DISTINCT ON (source, external_id) local_content_id
FROM local.local_contents
WHERE deleted = FALSE AND external_id IS NOT NULL
ORDER BY source, external_id, collected_at DESC, created_at DESC
);
-- 새 유일성: 출처가 준 id 하나면 한 행. 지역과 무관하다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_external
ON local.local_contents (source, external_id)
WHERE deleted = FALSE AND external_id IS NOT NULL;
-- 지역 이야기: 한 지역에 종류당 한 벌.
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_kind
ON local.local_contents (region_code, kind)
WHERE deleted = FALSE AND kind IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_local_contents_type_region
ON local.local_contents (content_type, region_code) WHERE deleted = FALSE;
-- ── 2. spots 에 있던 실체를 공용 테이블로 접어 넣는다 ─────────────────────
INSERT INTO local.local_contents (region_code, content_type, source, external_id, title, body,
latitude, longitude, display_end_at, collected_at, status)
SELECT s.region_code, s.content_type, s.source, s.external_id, s.title, s.body,
s.latitude, s.longitude, s.display_end_at, s.collected_at,
2 -- 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정)
FROM local.spots s
WHERE s.deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM local.local_contents lc
WHERE lc.deleted = FALSE AND lc.external_id = s.external_id AND lc.source = s.source
);
-- ── 3. 업장 ↔ 콘텐츠 관계 ────────────────────────────────────────────────
-- 기존 place_contents 는 값을 통째로 들고 있던 옛 구조다. 이름을 비켜 두고 새로 만든다.
ALTER TABLE IF EXISTS local.place_contents RENAME TO place_contents_legacy;
ALTER INDEX IF EXISTS local.uq_place_contents_keyed RENAME TO uq_place_contents_legacy_keyed;
ALTER INDEX IF EXISTS local.idx_place_contents_place RENAME TO idx_place_contents_legacy_place;
CREATE TABLE IF NOT EXISTS local.place_contents (
place_id uuid NOT NULL, -- 사업장(place.places.place_id)
local_content_id uuid NOT NULL, -- 공용 콘텐츠(local.local_contents)
distance_m INTEGER NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (place_id, local_content_id)
);
CREATE INDEX IF NOT EXISTS idx_place_contents_place
ON local.place_contents (place_id) WHERE deleted = FALSE;
-- place_spots(0002) 의 관계를 옮긴다. spot → 같은 external_id 의 공용 콘텐츠로 잇는다.
INSERT INTO local.place_contents (place_id, local_content_id, distance_m, hidden)
SELECT ps.place_id, lc.local_content_id, ps.distance_m, ps.hidden
FROM local.place_spots ps
JOIN local.spots s ON s.spot_id = ps.spot_id AND s.deleted = FALSE
JOIN local.local_contents lc
ON lc.source = s.source AND lc.external_id = s.external_id AND lc.deleted = FALSE
WHERE ps.deleted = FALSE
ON CONFLICT (place_id, local_content_id) DO NOTHING;
-- ── 4. 갈라놓았던 테이블을 접는다 ────────────────────────────────────────
-- 아직 읽는 코드가 없다(0002·0003 은 테이블만 만들었다) — 지금이 지우기 가장 싸다.
DROP TABLE IF EXISTS local.place_spots;
DROP TABLE IF EXISTS local.spots;
DROP TABLE IF EXISTS local.region_stories;
-- ★ place_contents_legacy 는 남긴다. 읽는 코드가 새 구조로 옮겨간 뒤 별도 번호로 뗀다 —
-- 한 번에 갈아엎으면 되돌릴 자리가 없다.

View File

@ -0,0 +1,85 @@
-- 0005 · 스키마를 해체하고 이름으로 소속을 말한다
--
-- ★ 왜 (2026-09-09)
-- 스키마를 나누는 이유는 셋뿐이다 — ① 같은 이름을 여러 번 쓰려고 ② 권한을 덩어리로 주려고
-- ③ 읽는 사람에게 경계를 보여주려고. 이 프로젝트에선 ①②가 성립하지 않는다:
-- · `web4ai_db` 는 이 제품 전용 database 다. 다른 프로젝트는 다른 database 에 있고,
-- 19개 테이블 이름이 전부 다르다 — 충돌할 상대가 애초에 없다.
-- · 접속 계정이 하나다. 스키마별 GRANT 를 준 적이 없고, 실제 권한 분리는 DB 밖에서
-- 포트로 한다(솔루션 9800 / 어드민 9801, ADMIN_API_BIND=127.0.0.1).
-- ③만 남는데, 그 값을 치르는 방식이 틀렸다:
-- · 19개 중 9개가 스키마 이름을 다시 말한다(place.places · fact.facts · site.sites ·
-- local.local_contents · job.jobs …). 폴더 이름이 파일 이름에 이미 들어 있으면
-- 그 폴더는 정보를 더하지 못한다.
-- · **결정적으로, 경계가 조인 방향과 반대다.** `places.place_id` 를 참조하는 테이블이
-- 11개인데 그중 `place` 스키마 안에 있는 건 **하나도 없다**. 관계를 잇는 컬럼이
-- 매번 경계를 넘는다면 그건 경계가 아니다. 특히 업장↔지역 관계 테이블은
-- 존재 자체가 두 스키마를 잇는 것이라 어디에 둬도 틀린다.
--
-- 그래서 스키마를 없애고 **접두어가 소속을 말한다.**
-- place_* 한 업장의 것 · site_* 한 사이트의 것 · users/jobs 아무에게도 안 속함
-- area_contents 만 접두어가 없다 — **그게 공유물이라는 표시다.**
-- 이 규칙이 서면 오늘 같은 복제 사고는 이름만 봐도 걸린다(place_ 붙은 테이블에 축제가
-- 들어가 있으면 그 자체로 틀린 것이다).
--
-- ★ 데이터는 옮기지 않는다. RENAME 뿐이라 잃는 행이 없다.
-- ── 사람 · 큐 (아무에게도 안 속한다) ──────────────────────────────────────
ALTER TABLE IF EXISTS company.users SET SCHEMA public;
ALTER TABLE IF EXISTS job.jobs SET SCHEMA public;
-- ── 업장 ────────────────────────────────────────────────────────────────
ALTER TABLE IF EXISTS place.places SET SCHEMA public;
ALTER TABLE IF EXISTS place.place_links SET SCHEMA public;
ALTER TABLE IF EXISTS place.units SET SCHEMA public;
ALTER TABLE IF EXISTS place.media SET SCHEMA public;
ALTER TABLE IF EXISTS fact.facts SET SCHEMA public;
ALTER TABLE IF EXISTS fact.faqs SET SCHEMA public;
-- 이름이 무엇에 속하는지, 그리고 무엇을 담는지 말하게 한다.
ALTER TABLE IF EXISTS public.place_links RENAME TO place_channels; -- 네이버·야놀자 등 채널
ALTER TABLE IF EXISTS public.units RENAME TO place_units; -- 객실·메뉴·프로그램
ALTER TABLE IF EXISTS public.media RENAME TO place_photos; -- 실제로 사진만 담는다
ALTER TABLE IF EXISTS public.facts RENAME TO place_facts;
ALTER TABLE IF EXISTS public.faqs RENAME TO place_faqs;
-- ── 지역 (여럿이 나눠 쓴다) ──────────────────────────────────────────────
ALTER TABLE IF EXISTS local.local_contents SET SCHEMA public;
ALTER TABLE IF EXISTS local.place_contents SET SCHEMA public;
-- 접두어 없는 유일한 테이블. 공유물이라는 표시다.
ALTER TABLE IF EXISTS public.local_contents RENAME TO area_contents;
-- 값이 아니라 **관계**다 — 이름이 그걸 말해야 한다.
ALTER TABLE IF EXISTS public.place_contents RENAME TO place_area_refs;
-- ── 사이트 ──────────────────────────────────────────────────────────────
ALTER TABLE IF EXISTS site.sites SET SCHEMA public;
ALTER TABLE IF EXISTS site.site_contents SET SCHEMA public;
ALTER TABLE IF EXISTS site.site_versions SET SCHEMA public;
ALTER TABLE IF EXISTS site.publish_logs SET SCHEMA public;
-- 섹션의 '내용'이다. site_contents 는 무엇의 콘텐츠인지 말하지 않는다.
ALTER TABLE IF EXISTS public.site_contents RENAME TO site_sections;
ALTER TABLE IF EXISTS public.publish_logs RENAME TO site_publish_logs;
-- ── 아직 남기는 것 ──────────────────────────────────────────────────────
-- 빈 테이블(place_aliases · routes · nearby_links · ai_check_results)과 이관 잔재
-- (place_contents_legacy)는 0006 에서 판단한다. 한 파일에 두 가지 일을 넣지 않는다.
ALTER TABLE IF EXISTS place.place_aliases SET SCHEMA public;
ALTER TABLE IF EXISTS local.routes SET SCHEMA public;
ALTER TABLE IF EXISTS local.nearby_links SET SCHEMA public;
ALTER TABLE IF EXISTS site.ai_check_results SET SCHEMA public;
ALTER TABLE IF EXISTS local.place_contents_legacy SET SCHEMA public;
ALTER TABLE IF EXISTS public.routes RENAME TO place_routes;
ALTER TABLE IF EXISTS public.place_aliases RENAME TO place_names;
-- ai_check_results 는 place_id 가 키다 — 사이트가 아니라 **업장**이 AI 검색에 나오는지를
-- 재는 것이라, 사이트를 안 만들어도 잴 수 있다. site_ 가 아니라 place_ 다.
ALTER TABLE IF EXISTS public.ai_check_results RENAME TO place_ai_checks;
-- ── 빈 스키마 정리 ──────────────────────────────────────────────────────
-- ★ CASCADE 를 쓰지 않는다. 옮기다 빠뜨린 테이블이 있으면 여기서 실패해야 한다 —
-- CASCADE 면 그 테이블이 조용히 함께 지워진다.
DROP SCHEMA IF EXISTS company RESTRICT;
DROP SCHEMA IF EXISTS place RESTRICT;
DROP SCHEMA IF EXISTS fact RESTRICT;
DROP SCHEMA IF EXISTS local RESTRICT;
DROP SCHEMA IF EXISTS site RESTRICT;
DROP SCHEMA IF EXISTS job RESTRICT;

View File

@ -0,0 +1,27 @@
-- 0006 · 한 번도 쓰지 않은 테이블과 이관 잔재를 뗀다
--
-- 실측(2026-09-09) — 네 테이블이 **행 0건**이다. 만들어 두고 기능이 붙지 않았다:
-- place_names(별칭) · place_routes(가는 길) · nearby_links(주변) · place_ai_checks(AI 노출 점검)
-- 화면에 나오지 않는 테이블을 유지비만 내며 들고 있을 이유가 없다 — 필요해지는 날
-- 그때의 요구에 맞춰 만드는 편이 낫다(지금 모양이 그때 맞을 거라는 보장도 없다).
--
-- ★ nearby_links 는 area_contents 와 역할이 겹친다. 둘 다 "업장 주변의 장소"인데
-- 하나는 카카오 로컬, 하나는 TourAPI 였다. 겹치는 자리를 남겨 두면 다음 사람이
-- "어느 쪽이 진짜냐"를 다시 묻는다 — 오늘 spots/local_contents 로 이미 겪었다.
DROP TABLE IF EXISTS public.place_names;
DROP TABLE IF EXISTS public.place_routes;
DROP TABLE IF EXISTS public.nearby_links;
DROP TABLE IF EXISTS public.place_ai_checks;
-- 0004 에서 place_contents 를 관계 테이블로 바꾸며 옆으로 밀어 둔 옛 구조.
-- 값은 area_contents(198건) + place_area_refs(144건)로 전부 옮겨졌다.
DROP TABLE IF EXISTS public.place_contents_legacy;
-- ── 발행 이력은 합치지 않는다 ──────────────────────────────────────────
-- 처음엔 site_versions(52) 와 site_publish_logs(52) 가 1:1 이라 합치려 했다. 아니다.
-- 실측: action 분포가 publish 45 · rebuild 7 로 이미 섞여 있다. 같은 버전을 다시 굽거나
-- 내리면 그 순간 2:1 이 된다 — 지금 1:1 인 건 우연이고 **구조는 1:N** 이다.
-- 합쳤다면 두 번째 발행이 첫 기록을 덮었을 것이다.
-- 성격도 다르다: site_versions 는 "무엇을 구웠나"(스냅샷·JSON-LD), publish_logs 는
-- "언제 무슨 일이 있었나"(동작·결과·반려사유·실행자)다.

View File

@ -0,0 +1,18 @@
-- 0007 · 지역 이야기 다섯 종이 한 지역에 나란히 설 수 있게 한다
--
-- `uq_local_contents_single (region_code, content_type) WHERE external_id IS NULL` 은
-- **날씨**를 위해 만든 인덱스다 — 날씨는 출처 id 가 없고 지역당 한 행이면 된다.
-- 그런데 지역 이야기도 external_id 가 없다(유일성의 근거가 `kind` 다). 그래서 같은 지역의
-- 가요·인물·연표·엽서·퀴즈 다섯이 `(region_code, content_type=6)` 하나를 두고 부딪친다 —
-- 실측(2026-09-09, 52군산시): 생성은 54건 다 됐는데 저장은 첫 종류만 들어가고 나머지 넷이
-- unique 위반으로 떨어졌다. 잡은 "성공"으로 끝나고 화면만 비어 있다 — 조용히 틀리는 종류다.
--
-- 고치는 방향: `single` 은 **kind 가 없는 행**(=날씨)에만 건다. 이야기의 유일성은
-- 0004 가 만든 `uq_local_contents_kind (region_code, kind)` 가 이미 책임진다.
-- 인덱스 둘이 같은 행을 두고 다투지 않게, 각자 자기 몫만 보게 가른다.
DROP INDEX IF EXISTS public.uq_local_contents_single;
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_single
ON public.area_contents (region_code, content_type)
WHERE deleted = FALSE AND external_id IS NULL AND kind IS NULL;

View File

@ -0,0 +1,81 @@
-- 0008 · 공용과 개인화를 이름으로 가른다
--
-- 규칙 두 줄로 정리했다(2026-09-09):
-- area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → **렌더러 모양 그대로** 담는다.
-- site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·사장님 편집).
--
-- 지금은 셋이 어긋나 있다:
-- 1) area_contents.body 가 TourAPI **원문 이름**(mapx·addr1·firstimage)이다. 렌더러가 읽는
-- 이름(name·location·imageUrl)으로 매번 빌드에서 바꿔 왔다 — 저장할 때 바꾸면 될 일이다.
-- 2) 거리·숨김이 place_area_refs 에 있다. 그건 **사이트마다 다른 값**이라 개인화 쪽이다.
-- 3) area_contents.kind 가 지역 이야기에만 있고 장소류는 NULL 이라, 개인화 행이 무엇을
-- 가리키는지 이름으로 알 수 없었다.
-- ── 1. 타입명을 모든 행에 채운다 ──────────────────────────────────────
-- ★ 인덱스를 **먼저** 뗀다. 옛 uq_local_contents_kind 는 (region_code, kind) 라 같은 지역의
-- 관광지 여러 건이 전부 kind='attraction' 이 되는 순간 부딪친다 — 실측(2026-09-09)에서
-- `(gunsan, festival) already exists` 로 이 UPDATE 가 통째로 막혔다.
-- 지역 이야기는 "지역 × 종류 한 벌"이 맞고, 장소류의 유일성은 uq_local_contents_external
-- (source, external_id) 이 잡는다. 그래서 조건에 external_id IS NULL 을 더해 둘을 가른다.
DROP INDEX IF EXISTS public.uq_local_contents_kind;
UPDATE public.area_contents SET kind = CASE content_type
WHEN 1 THEN 'weather' WHEN 2 THEN 'festival' WHEN 3 THEN 'attraction'
WHEN 4 THEN 'restaurant' WHEN 5 THEN 'course' END,
updated_at = now()
WHERE deleted = FALSE AND kind IS NULL AND content_type BETWEEN 1 AND 5;
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_kind
ON public.area_contents (region_code, kind)
WHERE deleted = FALSE AND kind IS NOT NULL AND external_id IS NULL;
-- ── 2. body 를 렌더러 모양으로 ────────────────────────────────────────
-- ★ 좌표는 body 에서 뺀다 — latitude/longitude 컬럼이 이미 그 자리다(0004). 일정 조립이
-- body.mapx 를 읽던 것을 컬럼으로 돌린다. 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다.
-- ★ lclsSystm2 만 남긴다. 렌더러는 안 쓰지만 서버가 쓴다(주변 맛집에서 같은 업태 제외).
-- ★ distance_m 은 여기서 사라진다. 사이트마다 다른 값이라 아래 3번으로 간다.
UPDATE public.area_contents SET body = (
jsonb_strip_nulls(jsonb_build_object(
'name', COALESCE(NULLIF(body->>'title', ''), title),
'searchQuery', COALESCE(NULLIF(body->>'title', ''), title),
'location', NULLIF(body->>'addr1', ''),
'imageUrl', NULLIF(body->>'firstimage', ''),
'lclsSystm2', NULLIF(body->>'lclsSystm2', '')
))
-- 축제는 기간·홈페이지가 더 붙는다. 화면의 배지(month)·기간 문자열은 읽을 때 만든다 —
-- 날짜 원값을 남겨 둬야 노출 기간 필터와 어긋나지 않는다.
|| CASE WHEN content_type = 2 THEN jsonb_strip_nulls(jsonb_build_object(
'eventstartdate', NULLIF(body->>'eventstartdate', ''),
'eventenddate', NULLIF(body->>'eventenddate', ''),
'homepage', NULLIF(body->>'homepage', ''),
'overview', NULLIF(body->>'overview', '')
)) ELSE '{}'::jsonb END
), updated_at = now()
WHERE deleted = FALSE AND content_type BETWEEN 2 AND 5 AND body ? 'contentid';
-- ── 3. 거리·숨김을 사이트 쪽으로 옮긴다 ───────────────────────────────
-- 사이트가 없는 업장(수집만 하고 발행 안 한 곳)은 옮길 자리가 없다 — 그때는 다음 수집이
-- 사이트를 만들며 다시 쓴다. 여기서 sites 행을 만들지 않는다(발행 정책은 build_service 것이다).
-- ★ `items` 배열이 아니라 **ref → 값 맵**이다. items 는 화면에 순서대로 서는 항목들의 모양이고
-- (songs·people·… 이 그 모양이다), 여기 담기는 건 "공용 항목 하나에 이 사이트가 덧붙인 값"
-- 조회표다. 배열로 두면 읽을 때마다 훑어야 하고 ref 가 항목마다 한 번 더 들어간다.
-- 순서도 여기서 정하지 않는다 — 정렬 기준(가까운 순·사진 있는 것 먼저)은 읽는 쪽이 갖는다.
INSERT INTO public.site_sections (site_id, section_id, data, source_type, status, sort_order)
SELECT s.site_id,
'local',
jsonb_build_object('kind', 'local', 'places', COALESCE(jsonb_object_agg(
r.local_content_id::text,
jsonb_build_object('kind', a.kind, 'distanceMeters', r.distance_m, 'hidden', r.hidden)
), '{}'::jsonb)),
2, -- SourceType.API — 사람이 쓴 게 아니라 수집이 만든 개인화 값이다
1,
0
FROM public.place_area_refs r
JOIN public.area_contents a ON a.local_content_id = r.local_content_id AND a.deleted = FALSE
JOIN public.sites s ON s.place_id = r.place_id AND s.deleted = FALSE
WHERE r.deleted = FALSE
GROUP BY s.site_id
ON CONFLICT DO NOTHING;
-- place_area_refs 는 아직 지우지 않는다. 읽는 코드가 옮겨 간 것을 확인한 뒤 별도 번호로 뗀다 —
-- 같은 마이그레이션에서 옮기고 지우면, 이관이 틀렸을 때 되돌릴 원본이 없다.

View File

@ -0,0 +1,34 @@
# 마이그레이션 — 이미 만들어진 DB 를 따라오게 하는 파일
`init-data/init.sql` 은 **새 DB 를 세우는 전체 DDL** 이고 계속 최신을 유지한다.
여기 파일들은 **이미 데이터가 든 DB** 를 그 최신으로 끌어올린다. 둘 다 필요하다.
## 왜 생겼나 (2026-09-09)
`DECISIONS.md` 는 누적 ALTER 를 없애면서 이렇게 적어 뒀다 —
*"아직 git·서버 어디에도 안 올라가 보정할 기존 DB 가 없다 … **운영 DB 가 생기는 순간
다시 필요해진다**"*. 그 순간이 왔다.
실제로 터졌다: 로컬 DB 에 `local.place_contents` 테이블과 `place.places.external_category`
컬럼이 없었다. `init.sql` 에는 둘 다 있었지만 그 파일은 **DB 를 처음 만들 때만** 돈다.
TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고,
화면에는 "그냥 안 나오는 것"으로 보였다 — 원인을 짚는 데 한참 걸렸다.
## 규칙
- 파일명 `NNNN_한글_요약.sql` — 번호는 이어 붙인다. 지운 번호를 재사용하지 않는다.
- **재실행 안전하게 쓴다**(`IF NOT EXISTS` · `ADD COLUMN IF NOT EXISTS`).
적용 기록이 있어도 사람이 손으로 한 번 더 돌릴 수 있다.
- 한 파일 = 한 가지 변경. 여러 테이블을 건드려도 목적이 하나면 한 파일이다.
- **`init.sql` 도 같이 고친다.** 새 DB 는 그 파일만 읽는다 — 여기만 고치면
새로 세운 DB 에 그 변경이 없다(`tests/test_schema_ddl.py` 가 ORM 과의 어긋남은 잡지만,
init.sql 과 이 폴더의 어긋남은 아무도 안 잡는다).
## 적용
```bash
cd solution/backend && .venv/bin/python scripts/migrate.py # 안 돌린 것만
cd solution/backend && .venv/bin/python scripts/migrate.py --dry-run # 목록만
```
적용 기록은 `public.schema_migrations` 에 남는다. 이미 있는 번호는 건너뛴다.

View File

@ -19,6 +19,10 @@
{ "key": "breakfast", "label": "조식 제공", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "baby_amenities", "label": "유아용품 비치", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "pickup_service", "label": "픽업 서비스", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "facilities", "label": "부대시설", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "total_rooms", "label": "객실 수", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "실" },
{ "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" },
{ "key": "building_scale", "label": "규모", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "intro", "label": "숙소 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "room_type", "label": "객실 타입", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
@ -29,6 +33,15 @@
{ "key": "bathroom_count", "label": "욕실 수", "type": "number", "scope": "unit", "required": false, "critical": false, "allow_llm": false, "unit": "개" },
{ "key": "has_kitchen", "label": "주방 여부", "type": "bool", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "has_aircon", "label": "에어컨", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_bathroom", "label": "욕실", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_tv", "label": "TV", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_internet", "label": "인터넷", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_refrigerator", "label": "냉장고", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_hairdryer", "label": "드라이기", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_toiletries", "label": "세면도구", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_table", "label": "테이블", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_sofa", "label": "소파", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_home_theater", "label": "홈시어터", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "view", "label": "전망", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "weekday_price", "label": "주중 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },
{ "key": "weekend_price", "label": "주말 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },

View File

@ -12,6 +12,7 @@ from common.enums import (
UserRole,
PlaceStatus,
FactStatus,
SourceType,
MediaStatus,
SiteStatus,
BuildStatus,
@ -46,7 +47,6 @@ class MainTableMixin(_DBTypeMixin):
# ERD 도메인 모델
class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users"
__table_args__ = {"schema": "company"}
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
@ -78,12 +78,11 @@ class places(MainTableMixin, MAIN_BASE):
__tablename__ = "places"
__table_args__ = (
Index("idx_places_region_code", "region_code", postgresql_where=text("deleted = false")),
{"schema": "place"},
)
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다.
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(company.users)
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users)
name = Column(String(200), nullable=False) # 상호명(입력값)
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
@ -99,6 +98,10 @@ class places(MainTableMixin, MAIN_BASE):
latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True)
region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회)
# 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다.
# ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고,
# 이 값은 그 폴백이다(services/local_content_service._own_food_class).
external_category = Column(String(200), nullable=True)
verified_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미검증 → 수집·발행 금지
verified_by = Column(UUID(as_uuid=True), nullable=True)
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
@ -106,25 +109,14 @@ class places(MainTableMixin, MAIN_BASE):
content_updated_at = Column(DateTime(timezone=True), nullable=True)
class place_aliases(MainTableMixin, MAIN_BASE):
"""상호 별칭. 동명 업소 구분과 재검색에 쓴다(옛 상호, '본관/별관' 표기 등)."""
__tablename__ = "place_aliases"
__table_args__ = {"schema": "place"}
alias_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
alias = Column(String(200), nullable=False)
source_type = Column(SmallInteger, nullable=False) # SourceType
class place_links(MainTableMixin, MAIN_BASE):
class place_channels(MainTableMixin, MAIN_BASE):
"""Perplexity 가 발견한 채널 URL.
★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다.
raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다."""
__tablename__ = "place_links"
__tablename__ = "place_channels"
__table_args__ = (
Index(
"uq_place_links_place_url",
@ -133,7 +125,6 @@ class place_links(MainTableMixin, MAIN_BASE):
unique=True,
postgresql_where=text("deleted = false"),
),
{"schema": "place"},
)
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -148,12 +139,11 @@ class place_links(MainTableMixin, MAIN_BASE):
raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results)
class units(MainTableMixin, MAIN_BASE):
class place_units(MainTableMixin, MAIN_BASE):
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
__tablename__ = "units"
__table_args__ = {"schema": "place"}
__tablename__ = "place_units"
unit_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
@ -161,15 +151,14 @@ class units(MainTableMixin, MAIN_BASE):
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class media(MainTableMixin, MAIN_BASE):
class place_photos(MainTableMixin, MAIN_BASE):
"""사진. Gemini Vision 이 분류 라벨과 alt 를 만든다.
★ source_type 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2),
결론에 따라 발행 시 source_type 으로 걸러낼 수 있어야 한다.
★ vision_confidence 가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다."""
__tablename__ = "media"
__table_args__ = {"schema": "place"}
__tablename__ = "place_photos"
media_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
@ -190,7 +179,7 @@ class media(MainTableMixin, MAIN_BASE):
# ============================================================
# fact : 사실 / FAQ
# ============================================================
class facts(MainTableMixin, MAIN_BASE):
class place_facts(MainTableMixin, MAIN_BASE):
"""★ 가장 중요한 테이블. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다.
- key 는 업종 스키마(common/category_schema)에 정의된 것만 허용한다.
@ -200,7 +189,7 @@ class facts(MainTableMixin, MAIN_BASE):
활성 유니크: 같은 (place, unit, key) 로 살아있는 fact 는 1건. REJECTED/EXPIRED 는 이력으로 남기므로 제외한다."""
__tablename__ = "facts"
__tablename__ = "place_facts"
__table_args__ = (
# unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다.
# 노출값은 (사업장, 단위, key) 당 1건. 후보(1,2)·이력(5,6)은 제외 — 재수집이 쌓일 수 있게.
@ -234,7 +223,6 @@ class facts(MainTableMixin, MAIN_BASE):
"status",
postgresql_where=text("deleted = false AND status IN (3, 4)"),
),
{"schema": "fact"},
)
fact_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -246,17 +234,16 @@ class facts(MainTableMixin, MAIN_BASE):
source_type = Column(SmallInteger, nullable=False) # SourceType — owner | api | crawl | llm
source_url = Column(String(1000), nullable=True)
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
verified_by = Column(UUID(as_uuid=True), nullable=True) # company.users.user_id
verified_by = Column(UUID(as_uuid=True), nullable=True) # users.user_id
verified_at = Column(DateTime(timezone=True), nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
expires_at = Column(DateTime(timezone=True), nullable=True) # 지나면 EXPIRED 전이 대상
class faqs(MainTableMixin, MAIN_BASE):
class place_faqs(MainTableMixin, MAIN_BASE):
"""FAQ. ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다."""
__tablename__ = "faqs"
__table_args__ = {"schema": "fact"}
__tablename__ = "place_faqs"
faq_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
@ -271,13 +258,13 @@ class faqs(MainTableMixin, MAIN_BASE):
# ============================================================
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
# ============================================================
class local_contents(MainTableMixin, MAIN_BASE):
class area_contents(MainTableMixin, MAIN_BASE):
"""지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 —
같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다.
★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다."""
__tablename__ = "local_contents"
__tablename__ = "area_contents"
__table_args__ = (
# external_id 가 있는 항목(축제·관광지·맛집)은 출처 고유 ID 로 중복을 막는다.
Index(
@ -296,7 +283,6 @@ class local_contents(MainTableMixin, MAIN_BASE):
unique=True,
postgresql_where=text("deleted = false AND external_id IS NULL"),
),
{"schema": "local"},
)
local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -313,49 +299,32 @@ class local_contents(MainTableMixin, MAIN_BASE):
display_end_at = Column(DateTime(timezone=True), nullable=True)
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지)
class routes(MainTableMixin, MAIN_BASE):
"""가는 길. 검증 상태(FactStatus)를 그대로 쓴다 — 틀린 경로 안내도 헛걸음을 만든다."""
__tablename__ = "routes"
__table_args__ = {"schema": "local"}
route_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
origin_name = Column(String(200), nullable=False) # 출발지 (예: "서울역")
transport = Column(SmallInteger, nullable=False) # TransportType
duration_min = Column(Integer, nullable=True)
distance_m = Column(Integer, nullable=True)
description = Column(Text, nullable=True)
source_type = Column(SmallInteger, nullable=False) # SourceType
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class nearby_links(MainTableMixin, MAIN_BASE):
"""주변 맛집·시설. 카카오 카테고리 검색 결과를 사업장에 붙인 것.
카테고리 검색은 좌표 변환보다 4배 비싸므로 region_code 단위 캐시(local_contents)에서 파생시킨다."""
__tablename__ = "nearby_links"
__table_args__ = {"schema": "local"}
nearby_link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
region_code = Column(String(10), nullable=True) # 어느 지역 캐시에서 왔는지
name = Column(String(200), nullable=False)
category_name = Column(String(100), nullable=True) # 카카오 category_name
kakao_place_id = Column(String(32), nullable=True)
distance_m = Column(Integer, nullable=True)
url = Column(String(1000), nullable=True)
# ★ 0004 에서 늘렸다. 좌표는 body 안에도 있지만 거리 계산이 행마다 JSON 을 펴야 해서 꺼냈다.
latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
# 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류. 장소류는 NULL.
kind = Column(String(50), nullable=True)
class place_area_refs(MainTableMixin, MAIN_BASE):
"""업장 ↔ 지역 콘텐츠. **업장별로 다른 것은 거리와 숨김뿐이다.**
★ 예전에는 이 테이블이 값을 통째로 들고 있었다(place_contents). 키가 place_id 라서
업장마다 TourAPI 응답이 복제됐다 — 실측(2026-09-09) 조이모텔 한 곳에 144행이고,
성남 중원구에 모텔이 열 곳 들어오면 같은 축제가 열 벌이 된다.
실체는 `area_contents` 에 한 행, 여기에는 관계만 남긴다.
★ hidden 은 재수집이 덮어쓰지 않는다 — 운영자가 뺀 것을 다음 갱신이 되살리면
숨긴 의미가 없다."""
__tablename__ = "place_area_refs"
place_id = Column(UUID(as_uuid=True), primary_key=True) # places.place_id
local_content_id = Column(UUID(as_uuid=True), primary_key=True) # area_contents.local_content_id
distance_m = Column(Integer, nullable=True) # 정렬·도보 시간의 원값
hidden = Column(Boolean, nullable=False, server_default=text("false"), default=False)
# ============================================================
# site : 사이트 / 버전 / 발행 로그 / AI 노출 점검
# ============================================================
class sites(MainTableMixin, MAIN_BASE):
"""발행 대상 사이트. 사업장당 1개.
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
@ -364,7 +333,6 @@ class sites(MainTableMixin, MAIN_BASE):
__table_args__ = (
Index("uq_sites_place", "place_id", unique=True, postgresql_where=text("deleted = false")),
Index("uq_sites_domain", "domain", unique=True, postgresql_where=text("deleted = false AND domain IS NOT NULL")),
{"schema": "site"},
)
site_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -383,13 +351,46 @@ class sites(MainTableMixin, MAIN_BASE):
# NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다(services/site_payload).
theme = Column(JSONB, nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value)
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site.site_versions.site_version_id
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id
published_at = Column(DateTime(timezone=True), nullable=True)
# 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을
# 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다.
thumbnail_url = Column(String(500), nullable=True)
class site_sections(MainTableMixin, MAIN_BASE):
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.
★ 왜 theme 에서 꺼냈나 (2026-09-09)
색·서체(디자인)와 섹션 콘텐츠가 `sites.theme` JSONB 한 칸에 같이 있었다.
실측(/s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%)다.
크기가 문제가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도
42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다
"누가 넣었나 · 확인됐나"를 물을 자리가 없었다.
★ 순서·on/off·배리에이션은 여전히 theme 이 갖는다. 여기는 **내용만** 든다.
★ shared_ref 가 있으면 값을 복제하지 않고 원본(region_stories 등)을 가리킨다 —
발행할 때 펼쳐 payload 에 싣는다."""
__tablename__ = "site_sections"
__table_args__ = (
Index(
"uq_site_contents_section",
"site_id", "section_id",
unique=True,
postgresql_where=text("deleted = false"),
),
)
site_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
site_id = Column(UUID(as_uuid=True), nullable=False, index=True)
section_id = Column(String(50), nullable=False) # 'songs' 'itinerary' 'video' 'people' …
data = Column(JSONB, nullable=False) # shared 의 XxxItem[] 계약
source_type = Column(SmallInteger, nullable=False, server_default=text("1"), default=SourceType.OWNER.value)
shared_ref = Column(UUID(as_uuid=True), nullable=True) # 공유 원본을 가리킬 때
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class site_versions(MainTableMixin, MAIN_BASE):
"""빌드 버전. ★ 정적 빌드 — snapshot 에 빌드 시점 데이터를 박제하고, 방문자는 DB 와 만나지 않는다.
★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다.
@ -399,7 +400,6 @@ class site_versions(MainTableMixin, MAIN_BASE):
__tablename__ = "site_versions"
__table_args__ = (
Index("uq_site_versions_no", "site_id", "version", unique=True, postgresql_where=text("deleted = false")),
{"schema": "site"},
)
site_version_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -413,11 +413,10 @@ class site_versions(MainTableMixin, MAIN_BASE):
built_at = Column(DateTime(timezone=True), nullable=True)
class publish_logs(MainTableMixin, MAIN_BASE):
class site_publish_logs(MainTableMixin, MAIN_BASE):
"""발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다."""
__tablename__ = "publish_logs"
__table_args__ = {"schema": "site"}
__tablename__ = "site_publish_logs"
publish_log_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
site_id = Column(UUID(as_uuid=True), nullable=False, index=True)
@ -429,27 +428,7 @@ class publish_logs(MainTableMixin, MAIN_BASE):
actor_user_id = Column(UUID(as_uuid=True), nullable=True)
class ai_check_results(MainTableMixin, MAIN_BASE):
"""AI 검색 노출 점검. 이 서비스의 목표 지표 —
AI 가 이 가게를 **우리 사이트를 근거로** 설명하는가, 아니면 여전히 OTA 를 인용하는가."""
__tablename__ = "ai_check_results"
__table_args__ = {"schema": "site"}
ai_check_result_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
engine = Column(SmallInteger, nullable=False) # AiEngine
query = Column(String(500), nullable=False) # 던진 질의
answer = Column(Text, nullable=True)
cited_urls = Column(JSONB, nullable=True) # 인용된 URL 목록
is_own_site_cited = Column(Boolean, nullable=False, server_default=text("false"), default=False) # ★ 핵심 지표
ota_cited = Column(Boolean, nullable=False, server_default=text("false"), default=False) # OTA 가 대신 인용됐는지
checked_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
# ============================================================
# job : 작업 큐 (PostgreSQL 을 큐로 — LPS 의 job 큐 구조 이식)
# ============================================================
class jobs(MainTableMixin, MAIN_BASE):
"""작업 큐. 수집·비전분석·빌드는 몇 분 걸려 동기 요청으로 처리할 수 없다.
@ -477,7 +456,6 @@ class jobs(MainTableMixin, MAIN_BASE):
unique=True,
postgresql_where=text("status IN (1, 2) AND dedupe_key IS NOT NULL"),
),
{"schema": "job"},
)
# ★ 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라

View File

@ -259,7 +259,7 @@ FACT_STATUS_TRANSITIONS = {
class LinkChannel(CodeEnum):
"""place_links.channel 코드값. Perplexity 가 발견하는 채널 종류."""
"""place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류."""
YANOLJA = 1 # 야놀자
GOODCHOICE = 2 # 여기어때
@ -291,10 +291,32 @@ VISION_AUTO_APPROVE_CONFIDENCE = 0.7
class LocalContentType(CodeEnum):
"""local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류."""
WEATHER = 1 # 날씨 (Open-Meteo)
FESTIVAL = 2 # 축제 (TourAPI 행사정보, 주 1회)
ATTRACTION = 3 # 관광지 (TourAPI 지역기반, 월 1회)
RESTAURANT = 4 # 주변 맛집 (카카오 카테고리 검색)
WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시)
# ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25
FESTIVAL = 2 # 축제/공연/행사 (15)
ATTRACTION = 3 # 관광지 (12)
RESTAURANT = 4 # 음식점 (39)
COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다
# ★ 지역 이야기(가요·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 —
# 군산 이야기는 군산 숙소가 같이 쓴다. 다섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는,
# 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다.
STORY = 6
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 —
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
# ★ STORY 는 여기 없다. 그 다섯(songs·people·chronicle·postcard·quiz)은 kind 가 곧 타입명이고,
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
AREA_KIND = {
LocalContentType.WEATHER.value: "weather",
LocalContentType.FESTIVAL.value: "festival",
LocalContentType.ATTRACTION.value: "attraction",
LocalContentType.RESTAURANT.value: "restaurant",
LocalContentType.COURSE.value: "course",
}
# 지역 이야기 다섯. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
STORY_KINDS = ("songs", "people", "chronicle", "postcard", "quiz")
class LocalSource(CodeEnum):
@ -304,6 +326,9 @@ class LocalSource(CodeEnum):
TOUR_API = 2 # 한국관광공사. ★ 자체 areaCode 체계 — 카카오 행정구역 코드와 다르다
KAKAO_LOCAL = 3
OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록
# ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 —
# 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다.
LLM = 5
class LocalContentStatus(CodeEnum):

View File

@ -0,0 +1,24 @@
"""좌표 거리 — 공용 한 벌.
★ 같은 하버사인 공식이 tour_lookup(500m 동일업소 판정)·itinerary(일정 반경)·tour_api(축제 20km 필터)
세 곳에 각각 복사돼 있었다(2026-09-08 정리). 지구 반지름·단위가 파일마다 달라지면 같은 두 점의
거리가 모듈마다 다르게 나온다 — 거리로 무엇을 넣고 뺄지 정하는 코드가 셋이라 한 벌이어야 한다.
국내 범위라 하버사인(구면 근사)이면 충분하다. 오차는 수 m 수준으로, 우리가 쓰는 판정
(500m 이내·5~20km 반경)에서 결과를 바꾸지 않는다.
"""
import math
EARTH_RADIUS_M = 6_371_000.0
def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""두 좌표(위도, 경도) 사이의 거리(m). ★ 인자 순서는 (위도, 경도) — mapx/mapy 는 (경도, 위도)라 뒤집어 넣는다."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a))
def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
return haversine_m(lat1, lng1, lat2, lng2) / 1000.0

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts
from common.database.model.models import place_facts
from common.enums import ErrorType, FactStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -21,17 +21,17 @@ _ACTIVE = _PUBLISHED + _CANDIDATE
def _unit_cond(unit_id):
"""unit_id 는 NULL 비교라 == 로 걸면 안 된다(사업장 단위 fact 를 못 찾는다)."""
return facts.unit_id.is_(None) if unit_id is None else facts.unit_id == unit_id
return place_facts.unit_id.is_(None) if unit_id is None else place_facts.unit_id == unit_id
# fact CRUD. 항상 place_id 로 스코프한다.
class IFactCRUD(ABC):
@abstractmethod
async def add_fact(self, cdb: AsyncSession, fact: facts) -> ErrorType:
async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType:
pass
@abstractmethod
async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, facts]:
async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, place_facts]:
pass
@abstractmethod
@ -39,11 +39,11 @@ class IFactCRUD(ABC):
pass
@abstractmethod
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, facts]:
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]:
pass
@abstractmethod
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, facts]:
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]:
pass
@abstractmethod
@ -68,18 +68,18 @@ class IFactCRUD(ABC):
class FactCRUD(IFactCRUD):
async def add_fact(self, cdb: AsyncSession, fact: facts) -> ErrorType:
async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, fact)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, facts]:
async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, place_facts]:
try:
query = (
select(facts)
.where(facts.fact_id == fact_id, facts.place_id == place_id, facts.deleted == False) # noqa: E712
select(place_facts)
.where(place_facts.fact_id == fact_id, place_facts.place_id == place_id, place_facts.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -101,19 +101,19 @@ class FactCRUD(IFactCRUD):
active_only=True → REJECTED·EXPIRED 이력 제외 (관리 화면 기본: 노출값 + 후보)
"""
try:
conditions = [facts.place_id == place_id, facts.deleted == False] # noqa: E712
conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712
if unit_id is not None:
conditions.append(facts.unit_id == unit_id)
conditions.append(place_facts.unit_id == unit_id)
if publishable_only:
conditions.append(facts.status.in_(_PUBLISHED))
conditions.append(place_facts.status.in_(_PUBLISHED))
elif status is not None:
conditions.append(facts.status == status)
conditions.append(place_facts.status == status)
elif active_only:
conditions.append(facts.status.in_(_ACTIVE))
conditions.append(place_facts.status.in_(_ACTIVE))
# 노출값이 먼저, 그 아래 후보. 같은 key 끼리 붙어 보이게 정렬한다.
query = select(facts).where(and_(*conditions)).order_by(
facts.key.asc(), facts.status.desc(), facts.collected_at.desc()
query = select(place_facts).where(and_(*conditions)).order_by(
place_facts.key.asc(), place_facts.status.desc(), place_facts.collected_at.desc()
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
@ -121,17 +121,17 @@ class FactCRUD(IFactCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, facts]:
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]:
"""★ 지금 사이트에 나가고 있는 값. 없으면 (SUCCESS, None).
유니크 인덱스가 1건만 허용하므로 결과는 0 또는 1건이다."""
try:
query = (
select(facts)
select(place_facts)
.where(and_(
facts.place_id == place_id,
facts.key == key,
facts.deleted == False, # noqa: E712
facts.status.in_(_PUBLISHED),
place_facts.place_id == place_id,
place_facts.key == key,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_PUBLISHED),
_unit_cond(unit_id),
))
.limit(1)
@ -144,20 +144,20 @@ class FactCRUD(IFactCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, facts]:
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]:
"""같은 출처가 이미 올려둔 후보. 재수집이 같은 후보를 계속 쌓지 않도록 갱신 대상을 찾는다."""
try:
query = (
select(facts)
select(place_facts)
.where(and_(
facts.place_id == place_id,
facts.key == key,
facts.source_type == source_type,
facts.deleted == False, # noqa: E712
facts.status.in_(_CANDIDATE),
place_facts.place_id == place_id,
place_facts.key == key,
place_facts.source_type == source_type,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_CANDIDATE),
_unit_cond(unit_id),
))
.order_by(facts.collected_at.desc())
.order_by(place_facts.collected_at.desc())
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -176,7 +176,7 @@ class FactCRUD(IFactCRUD):
values = {"collected_at": ts, "updated_at": ts}
if source_url:
values["source_url"] = source_url
query = update(facts).where(facts.fact_id == fact_id, facts.deleted == False).values(**values) # noqa: E712
query = update(place_facts).where(place_facts.fact_id == fact_id, place_facts.deleted == False).values(**values) # noqa: E712
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
@ -186,8 +186,8 @@ class FactCRUD(IFactCRUD):
"""기존 후보를 새 수집값으로 갱신. 같은 출처의 후보가 계속 쌓이는 것을 막는다."""
try:
query = (
update(facts)
.where(facts.fact_id == fact_id, facts.status.in_(_CANDIDATE), facts.deleted == False) # noqa: E712
update(place_facts)
.where(place_facts.fact_id == fact_id, place_facts.status.in_(_CANDIDATE), place_facts.deleted == False) # noqa: E712
.values(value=value, source_url=source_url, status=status, collected_at=ts, updated_at=ts)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
@ -202,11 +202,11 @@ class FactCRUD(IFactCRUD):
허용 전이 판정 자체는 service 가 FACT_STATUS_TRANSITIONS 로 먼저 한다."""
try:
query = (
update(facts)
update(place_facts)
.where(
facts.fact_id == fact_id,
facts.status.in_(tuple(from_statuses)),
facts.deleted == False, # noqa: E712
place_facts.fact_id == fact_id,
place_facts.status.in_(tuple(from_statuses)),
place_facts.deleted == False, # noqa: E712
)
.values(status=to_status, updated_at=GTime.UTC(), **data)
)
@ -221,15 +221,15 @@ class FactCRUD(IFactCRUD):
지우지 않고 이력으로 남긴다 — 예전에 뭐가 나갔는지 추적할 수 있어야 한다."""
try:
conditions = [
facts.place_id == place_id,
facts.key == key,
facts.deleted == False, # noqa: E712
facts.status.in_(_PUBLISHED),
place_facts.place_id == place_id,
place_facts.key == key,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_PUBLISHED),
_unit_cond(unit_id),
]
if except_fact_id is not None:
conditions.append(facts.fact_id != except_fact_id)
query = update(facts).where(and_(*conditions)).values(status=FactStatus.EXPIRED.value, updated_at=ts)
conditions.append(place_facts.fact_id != except_fact_id)
query = update(place_facts).where(and_(*conditions)).values(status=FactStatus.EXPIRED.value, updated_at=ts)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
@ -241,15 +241,15 @@ class FactCRUD(IFactCRUD):
후보를 그대로 두면 사람 확인 큐에 이미 처리된 항목이 계속 남는다."""
try:
conditions = [
facts.place_id == place_id,
facts.key == key,
facts.deleted == False, # noqa: E712
facts.status.in_(_CANDIDATE),
place_facts.place_id == place_id,
place_facts.key == key,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_CANDIDATE),
_unit_cond(unit_id),
]
if except_fact_id is not None:
conditions.append(facts.fact_id != except_fact_id)
query = update(facts).where(and_(*conditions)).values(status=FactStatus.REJECTED.value, updated_at=ts)
conditions.append(place_facts.fact_id != except_fact_id)
query = update(place_facts).where(and_(*conditions)).values(status=FactStatus.REJECTED.value, updated_at=ts)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import faqs
from common.database.model.models import place_faqs
from common.enums import ErrorType, FactStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -21,7 +21,7 @@ class IFaqCRUD(ABC):
pass
@abstractmethod
async def add_faq(self, cdb: AsyncSession, faq: faqs) -> ErrorType:
async def add_faq(self, cdb: AsyncSession, faq: place_faqs) -> ErrorType:
pass
@abstractmethod
@ -33,26 +33,26 @@ class IFaqCRUD(ABC):
pass
@abstractmethod
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, faqs]:
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, place_faqs]:
pass
class FaqCRUD(IFaqCRUD):
async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool = False) -> Tuple[ErrorType, list]:
try:
conds = [faqs.place_id == place_id, faqs.deleted == False] # noqa: E712
conds.append(faqs.status.in_(_PUBLISHABLE if publishable_only else _ACTIVE))
query = select(faqs).where(and_(*conds)).order_by(faqs.sort_order.asc(), faqs.created_at.asc())
conds = [place_faqs.place_id == place_id, place_faqs.deleted == False] # noqa: E712
conds.append(place_faqs.status.in_(_PUBLISHABLE if publishable_only else _ACTIVE))
query = select(place_faqs).where(and_(*conds)).order_by(place_faqs.sort_order.asc(), place_faqs.created_at.asc())
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, faqs]:
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, place_faqs]:
try:
query = select(faqs).where(
faqs.faq_id == faq_id, faqs.place_id == place_id, faqs.deleted == False # noqa: E712
query = select(place_faqs).where(
place_faqs.faq_id == faq_id, place_faqs.place_id == place_id, place_faqs.deleted == False # noqa: E712
).limit(1)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
@ -64,7 +64,7 @@ class FaqCRUD(IFaqCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def add_faq(self, cdb: AsyncSession, faq: faqs) -> ErrorType:
async def add_faq(self, cdb: AsyncSession, faq: place_faqs) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, faq)
except Exception as ex:
@ -78,11 +78,11 @@ class FaqCRUD(IFaqCRUD):
재생성이 사람의 판단을 덮어쓰면 fact 쪽 규칙과 어긋난다."""
try:
query = (
update(faqs)
update(place_faqs)
.where(
faqs.place_id == place_id,
faqs.deleted == False, # noqa: E712
faqs.status.in_((FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)),
place_faqs.place_id == place_id,
place_faqs.deleted == False, # noqa: E712
place_faqs.status.in_((FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)),
)
.values(status=FactStatus.EXPIRED.value, updated_at=ts)
)
@ -94,8 +94,8 @@ class FaqCRUD(IFaqCRUD):
async def transition(self, cdb: AsyncSession, faq_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]:
try:
query = (
update(faqs)
.where(faqs.faq_id == faq_id, faqs.status.in_(tuple(from_statuses)), faqs.deleted == False) # noqa: E712
update(place_faqs)
.where(place_faqs.faq_id == faq_id, place_faqs.status.in_(tuple(from_statuses)), place_faqs.deleted == False) # noqa: E712
.values(status=to_status, updated_at=GTime.UTC(), **data)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)

View File

@ -45,7 +45,7 @@ class JobQueue:
) -> str | None:
"""잡 적재. dedupe_key 가 활성(PENDING/RUNNING) 중복이면 삽입 없이 None 반환."""
sql = text("""
INSERT INTO job.jobs (job_type, priority, payload, dedupe_key, max_attempts)
INSERT INTO jobs (job_type, priority, payload, dedupe_key, max_attempts)
VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma)
ON CONFLICT (dedupe_key) WHERE status IN (1, 2) AND dedupe_key IS NOT NULL
DO NOTHING
@ -69,7 +69,7 @@ class JobQueue:
"""대기 잡 1건을 원자적으로 점유. 없으면 None.
FOR UPDATE SKIP LOCKED 로 잠근 행을 같은 UPDATE 에서 RUNNING 으로 전이 → 이중 할당 불가."""
sql = text("""
UPDATE job.jobs SET
UPDATE jobs SET
status = 2,
worker_id = :wid,
lease_until = now() + make_interval(secs => :lease),
@ -77,7 +77,7 @@ class JobQueue:
attempts = attempts + 1,
updated_at = now()
WHERE job_id = (
SELECT job_id FROM job.jobs
SELECT job_id FROM jobs
WHERE status = 1 AND run_after <= now()
ORDER BY priority ASC, created_at ASC
FOR UPDATE SKIP LOCKED
@ -101,7 +101,7 @@ class JobQueue:
# ---- 완료/실패 (소유권 가드) ----
async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool:
sql = text("""
UPDATE job.jobs SET status = 3, result = CAST(:result AS jsonb),
UPDATE jobs SET status = 3, result = CAST(:result AS jsonb),
lease_until = NULL, worker_id = NULL, updated_at = now()
WHERE job_id = CAST(:id AS uuid) AND status = 2 AND worker_id = :wid
RETURNING job_id
@ -120,7 +120,7 @@ class JobQueue:
"""실패 처리. 시도 남으면 PENDING(run_after=백오프)으로 재큐, 소진되면 DEAD(dead-letter).
전이 후 status(JobStatus 값)를 반환. 소유 불일치면 None."""
sql = text("""
UPDATE job.jobs SET
UPDATE jobs SET
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
run_after = CASE WHEN attempts >= max_attempts THEN run_after
ELSE now() + make_interval(secs => :backoff) END,
@ -143,7 +143,7 @@ class JobQueue:
# ---- lease 갱신(heartbeat) / 회수(reaper) ----
async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool:
sql = text("""
UPDATE job.jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now()
UPDATE jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now()
WHERE job_id = CAST(:id AS uuid) AND worker_id = :wid AND status = 2
RETURNING job_id
""")
@ -158,7 +158,7 @@ class JobQueue:
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD.
회수된 job_id 목록 반환."""
sql = text("""
UPDATE job.jobs SET
UPDATE jobs SET
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
run_after = now(),
last_error = COALESCE(last_error, '') || ' [lease-expired reclaim]',
@ -181,7 +181,7 @@ class JobQueue:
sql = text("""
SELECT job_id, job_type, status, priority, attempts, max_attempts,
payload, result, last_error, run_after, run_started_at, created_at, updated_at
FROM job.jobs WHERE job_id = CAST(:id AS uuid)
FROM jobs WHERE job_id = CAST(:id AS uuid)
""")
async def run(s):
@ -201,7 +201,7 @@ class JobQueue:
"""dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다.
enqueue 가 중복으로 None 을 돌려줬을 때, 이미 돌고 있는 잡의 id 를 알려주기 위함."""
sql = text("""
SELECT job_id, job_type, status FROM job.jobs
SELECT job_id, job_type, status FROM jobs
WHERE dedupe_key = :dk AND status IN (1, 2)
LIMIT 1
""")
@ -220,7 +220,7 @@ class JobQueue:
async def counts(self) -> dict[str, int]:
"""상태별 잡 개수."""
async def run(s):
rows = (await s.execute(text("SELECT status, count(*) FROM job.jobs GROUP BY status"))).all()
rows = (await s.execute(text("SELECT status, count(*) FROM jobs GROUP BY status"))).all()
by_val = {int(st): int(c) for st, c in rows}
return {js.name: by_val.get(js.value, 0) for js in JobStatus}
@ -246,7 +246,7 @@ class JobQueue:
COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at) FILTER (WHERE status = 1)))::int, 0) AS oldest_pending_sec,
count(*) FILTER (WHERE last_error LIKE 'JobDeadlineExceeded%'
AND updated_at > now() - interval '1 hour') AS deadline_1h
FROM job.jobs
FROM jobs
""")
async def run(s):
@ -258,7 +258,7 @@ class JobQueue:
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움.
DEAD 가 아니거나 없으면 None. 같은 dedupe_key 의 활성 잡이 있으면 부분 유니크 위반."""
sql = text("""
UPDATE job.jobs SET status = 1, attempts = 0, run_after = now(),
UPDATE jobs SET status = 1, attempts = 0, run_after = now(),
lease_until = NULL, worker_id = NULL, run_started_at = NULL,
last_error = NULL, updated_at = now()
WHERE job_id = CAST(:jid AS uuid) AND status = 4

View File

@ -2,73 +2,134 @@ from sqlalchemy import and_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import local_contents
from common.database.model.models import area_contents
from common.enums import ErrorType, LocalContentType
from common.utils.gtime import GTime
class LocalContentCRUD:
async def list(self, db, status: int | None = None, region_code: str | None = None):
conds = [local_contents.deleted == False, local_contents.content_type == LocalContentType.FESTIVAL.value] # noqa: E712
"""축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은
관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨)."""
conds = [area_contents.deleted == False] # noqa: E712
if status is not None:
conds.append(local_contents.status == status)
conds.append(area_contents.status == status)
if region_code:
conds.append(local_contents.region_code == region_code)
conds.append(area_contents.region_code == region_code)
return await DB_SESSION_MNG.execute(
db, select(local_contents).where(and_(*conds)).order_by(local_contents.collected_at.desc())
db, select(area_contents).where(and_(*conds)).order_by(area_contents.collected_at.desc())
)
async def insert(self, db, row):
return await DB_SESSION_MNG.insert(db, row)
async def get_by_external_id(self, db, region_code: str, external_id: str):
err, rows = await DB_SESSION_MNG.execute(
db,
select(local_contents).where(
local_contents.region_code == region_code,
local_contents.content_type == LocalContentType.FESTIVAL.value,
local_contents.external_id == external_id,
local_contents.deleted == False, # noqa: E712
).limit(1),
)
return err, rows[0] if rows else None
async def publish(self, db, ids: list, user_id):
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(local_contents).where(
local_contents.local_content_id.in_(ids), local_contents.deleted == False # noqa: E712
update(area_contents).where(
area_contents.local_content_id.in_(ids), area_contents.deleted == False # noqa: E712
).values(status=2, published_at=GTime.UTC(), published_by=user_id, updated_at=GTime.UTC()),
)
async def update(self, db, content_id, data: dict):
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(local_contents).where(
local_contents.local_content_id == content_id, local_contents.deleted == False # noqa: E712
update(area_contents).where(
area_contents.local_content_id == content_id, area_contents.deleted == False # noqa: E712
).values(**data, updated_at=GTime.UTC()),
)
async def end(self, db, content_id):
return await self.update(db, content_id, {"status": 3})
async def list_keyed(self, db, region_code: str, content_type: int):
"""지역 × 종류의 외부 ID 있는 행 전부(축제·관광지·맛집). 동기화가 기존값과 비교할 때 쓴다."""
return await DB_SESSION_MNG.execute(
db,
select(area_contents).where(
area_contents.region_code == region_code,
area_contents.content_type == content_type,
area_contents.external_id.isnot(None),
area_contents.deleted == False, # noqa: E712
),
)
async def upsert_keyed(self, db, values: dict):
"""외부 ID 로 식별되는 행(축제·관광지·맛집)의 삽입/갱신.
★ uq_local_contents_keyed 부분 유니크 인덱스에 태운다 — 같은 지역을 두 번 동기화해도
중복 행이 생기지 않고 기존 값만 갱신된다."""
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.region_code, area_contents.content_type, area_contents.external_id],
index_where=and_(area_contents.deleted == False, area_contents.external_id.isnot(None)), # noqa: E712
set_={
"title": stmt.excluded.title,
"body": stmt.excluded.body,
"source": stmt.excluded.source,
"status": stmt.excluded.status,
"collected_at": stmt.excluded.collected_at,
"display_end_at": stmt.excluded.display_end_at,
"published_at": stmt.excluded.published_at,
"updated_at": GTime.UTC(),
},
)
return await DB_SESSION_MNG.add(db, stmt)
async def upsert_kind(self, db, values: dict):
"""지역 이야기 한 종류(가요·인물·…)의 삽입/갱신.
★ `uq_local_contents_kind`(region_code, kind — kind IS NOT NULL)에 태운다.
이 표의 규약은 **한 지역에 종류당 한 벌**이다(migrations/0004). 항목마다 한 행이 아니라
`body.items` 에 통째로 담긴다 — 사장님이 붙여넣는 같은 종류의 JSON 과 모양을 맞추기
위해서다. 다시 생성하면 그 한 행을 덮어쓴다.
★ external_id 는 넣지 않는다. 넣으면 `uq_local_contents_external`(source, external_id)에도
걸려, 종류가 다른 두 행이 같은 키로 충돌한다."""
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.region_code, area_contents.kind],
index_where=and_(area_contents.deleted == False, area_contents.kind.isnot(None)), # noqa: E712
set_={
"title": stmt.excluded.title,
"body": stmt.excluded.body,
"content_type": stmt.excluded.content_type,
"source": stmt.excluded.source,
"status": stmt.excluded.status,
"collected_at": stmt.excluded.collected_at,
"published_at": stmt.excluded.published_at,
"updated_at": GTime.UTC(),
},
)
return await DB_SESSION_MNG.add(db, stmt)
async def list_kinds(self, db, region_code: str):
"""지역의 이야기 행 전부(종류당 1행). cache-aside 판단에 쓴다."""
return await DB_SESSION_MNG.execute(
db,
select(area_contents).where(
area_contents.region_code == region_code,
area_contents.kind.isnot(None),
area_contents.deleted == False, # noqa: E712
),
)
async def get_weather(self, db, region_code: str):
err, rows = await DB_SESSION_MNG.execute(
db,
select(local_contents).where(
local_contents.region_code == region_code,
local_contents.content_type == LocalContentType.WEATHER.value,
local_contents.external_id.is_(None),
local_contents.deleted == False, # noqa: E712
select(area_contents).where(
area_contents.region_code == region_code,
area_contents.content_type == LocalContentType.WEATHER.value,
area_contents.external_id.is_(None),
area_contents.deleted == False, # noqa: E712
).limit(1),
)
return err, rows[0] if rows else None
async def upsert_weather(self, db, values: dict):
stmt = pg_insert(local_contents).values(**values)
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=[local_contents.region_code, local_contents.content_type],
index_where=and_(local_contents.deleted == False, local_contents.external_id.is_(None)), # noqa: E712
index_elements=[area_contents.region_code, area_contents.content_type],
index_where=and_(area_contents.deleted == False, area_contents.external_id.is_(None)), # noqa: E712
set_={
"source": stmt.excluded.source,
"body": stmt.excluded.body,

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import media
from common.database.model.models import place_photos
from common.enums import ErrorType, MediaStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -45,20 +45,20 @@ class MediaCRUD(IMediaCRUD):
★ alt_required=True 는 status 필터와 짝으로만 쓴다 — alt 가 빈 사진은 빌더가 렌더하지
않으므로(services/snapshot.py), '발행되면 실릴 것'을 물었을 때 승인만 보면 답이 틀린다."""
try:
conditions = [media.place_id == place_id, media.deleted == False] # noqa: E712
conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712
if status is not None:
conditions.append(media.status == status)
conditions.append(place_photos.status == status)
if unlabeled_only:
conditions.append(
or_(media.alt_text.is_(None), func.btrim(media.alt_text) == "")
or_(place_photos.alt_text.is_(None), func.btrim(place_photos.alt_text) == "")
)
if unit_id is not None:
conditions.append(media.unit_id == unit_id)
conditions.append(place_photos.unit_id == unit_id)
if alt_required:
# 공백만 있는 alt 도 빌더에선 '없음'이다 — 같은 기준으로 거른다.
conditions.append(media.alt_text.is_not(None))
conditions.append(func.btrim(media.alt_text) != "")
query = select(media).where(and_(*conditions)).order_by(media.sort_order.asc(), media.created_at.asc())
conditions.append(place_photos.alt_text.is_not(None))
conditions.append(func.btrim(place_photos.alt_text) != "")
query = select(place_photos).where(and_(*conditions)).order_by(place_photos.sort_order.asc(), place_photos.created_at.asc())
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
except Exception as ex:
@ -72,8 +72,8 @@ class MediaCRUD(IMediaCRUD):
라벨·alt 는 저장하되(사람이 보고 고칠 재료), 승인 상태로 올리지 않는 게 핵심이다."""
try:
query = (
update(media)
.where(media.media_id == media_id, media.deleted == False) # noqa: E712
update(place_photos)
.where(place_photos.media_id == media_id, place_photos.deleted == False) # noqa: E712
.values(label=label, alt_text=alt_text, vision_confidence=confidence, status=status, updated_at=ts)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
@ -85,8 +85,8 @@ class MediaCRUD(IMediaCRUD):
"""사람이 사진을 승인/반려한다."""
try:
query = (
update(media)
.where(media.media_id == media_id, media.place_id == place_id, media.deleted == False) # noqa: E712
update(place_photos)
.where(place_photos.media_id == media_id, place_photos.place_id == place_id, place_photos.deleted == False) # noqa: E712
.values(status=status, updated_at=ts)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)

View File

@ -0,0 +1,133 @@
from sqlalchemy import and_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents, place_area_refs
from common.utils.gtime import GTime
class PlaceContentCRUD:
"""업장 주변의 지역 콘텐츠.
★ 실체와 관계가 갈려 있다 (2026-09-09).
예전에는 한 테이블이 값을 통째로 들고 있었고 키가 place_id 라, 업장마다 TourAPI
응답이 복제됐다 — 실측 조이모텔 한 곳에 144행이고 같은 축제가 업장 수만큼 늘었다.
지금은 실체가 `area_contents` 에 한 행(전국 공용, external_id 로 유일)이고
`place_area_refs` 에는 그 업장에서만 다른 것 — 거리와 숨김 — 만 남는다.
그래서 읽을 때 조인이 하나 는다. 그 값으로 복제를 없앴다.
"""
async def list_by_place(self, db, place_id, *, include_hidden: bool = True):
"""이 업장 주변의 콘텐츠. 실체(area_contents)와 거리(place_area_refs)를 함께 준다."""
conds = [
place_area_refs.place_id == place_id,
place_area_refs.deleted == False, # noqa: E712
area_contents.deleted == False, # noqa: E712
]
if not include_hidden:
conds.append(place_area_refs.hidden == False) # noqa: E712
return await DB_SESSION_MNG.execute(
db,
select(
area_contents.local_content_id,
area_contents.content_type,
area_contents.external_id,
area_contents.title,
area_contents.body,
area_contents.latitude,
area_contents.longitude,
area_contents.display_end_at,
# ★ 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다.
place_area_refs.distance_m.label("distance_m"),
place_area_refs.hidden.label("hidden"),
)
.join(area_contents, area_contents.local_content_id == place_area_refs.local_content_id)
.where(and_(*conds))
.order_by(
area_contents.content_type.asc(),
place_area_refs.distance_m.asc(),
),
)
async def upsert_content(self, db, values: dict):
"""공용 콘텐츠 한 건. (source, external_id) 가 같으면 갱신한다 — 지역과 무관하게 한 벌이다.
★ RETURNING 을 쓰지 않는다. 세션 매니저의 execute 는 SELECT 만 받고
("DO NOT USE NON-SELECT QUERY IN DBJOB"), 쓰기는 add 로 간다. id 는 뒤이어 조회한다.
"""
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.source, area_contents.external_id],
index_where=and_(
area_contents.deleted == False, # noqa: E712
area_contents.external_id.isnot(None),
),
set_={
"title": stmt.excluded.title,
"body": stmt.excluded.body,
"latitude": stmt.excluded.latitude,
"longitude": stmt.excluded.longitude,
"region_code": stmt.excluded.region_code,
"display_end_at": stmt.excluded.display_end_at,
"collected_at": stmt.excluded.collected_at,
"updated_at": GTime.UTC(),
},
)
return await DB_SESSION_MNG.add(db, stmt)
async def find_content_id(self, db, source: int, external_id: str):
"""방금 upsert 한 공용 콘텐츠의 id."""
return await DB_SESSION_MNG.execute(
db,
select(area_contents.local_content_id).where(
area_contents.source == source,
area_contents.external_id == external_id,
area_contents.deleted == False, # noqa: E712
).limit(1),
)
async def upsert_ref(self, db, place_id, local_content_id, distance_m):
"""업장 ↔ 콘텐츠 관계. ★ hidden 은 건드리지 않는다 — 운영자가 숨긴 것을 재수집이 되살리면 안 된다."""
stmt = pg_insert(place_area_refs).values(
place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False,
)
stmt = stmt.on_conflict_do_update(
index_elements=[place_area_refs.place_id, place_area_refs.local_content_id],
set_={"distance_m": stmt.excluded.distance_m, "deleted": False, "updated_at": GTime.UTC()},
)
return await DB_SESSION_MNG.add(db, stmt)
async def soft_delete_missing(self, db, place_id, keep_ids: set):
"""이번 응답에 없는 **관계**를 끊는다. 실체(area_contents)는 지우지 않는다 —
다른 업장이 같은 장소를 가리키고 있을 수 있다."""
err, rows = await DB_SESSION_MNG.execute(
db,
select(place_area_refs.local_content_id).where(
place_area_refs.place_id == place_id,
place_area_refs.deleted == False, # noqa: E712
),
)
# 단일 컬럼 SELECT 라 행이 스칼라로 온다.
# ★ 단일 컬럼 SELECT 는 세션 매니저가 scalars() 로 편다 — 행이 곧 값이다.
gone = [r for r in (rows or []) if r not in keep_ids]
if not gone:
return err, 0
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(place_area_refs)
.where(place_area_refs.place_id == place_id, place_area_refs.local_content_id.in_(gone))
.values(deleted=True, updated_at=GTime.UTC()),
)
async def set_hidden(self, db, place_id, local_content_id, hidden: bool):
"""이 업장에서만 숨긴다. 실체는 그대로라 다른 업장에는 계속 보인다."""
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(place_area_refs)
.where(
place_area_refs.place_id == place_id,
place_area_refs.local_content_id == local_content_id,
place_area_refs.deleted == False, # noqa: E712
)
.values(hidden=hidden, updated_at=GTime.UTC()),
)

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_links, places, units
from common.database.model.models import place_channels, places, place_units
from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -38,11 +38,11 @@ class IPlaceCRUD(ABC):
pass
@abstractmethod
async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, units]:
async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, place_units]:
pass
@abstractmethod
async def add_unit(self, cdb: AsyncSession, unit: units) -> ErrorType:
async def add_unit(self, cdb: AsyncSession, unit: place_units) -> ErrorType:
pass
@abstractmethod
@ -50,7 +50,7 @@ class IPlaceCRUD(ABC):
pass
@abstractmethod
async def add_link(self, cdb: AsyncSession, link: place_links) -> ErrorType:
async def add_link(self, cdb: AsyncSession, link: place_channels) -> ErrorType:
pass
@abstractmethod
@ -156,9 +156,9 @@ class PlaceCRUD(IPlaceCRUD):
async def list_units(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, list]:
try:
query = (
select(units)
.where(units.place_id == place_id, units.deleted == False) # noqa: E712
.order_by(units.sort_order.asc(), units.created_at.asc())
select(place_units)
.where(place_units.place_id == place_id, place_units.deleted == False) # noqa: E712
.order_by(place_units.sort_order.asc(), place_units.created_at.asc())
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
@ -166,11 +166,11 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, units]:
async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, place_units]:
try:
query = (
select(units)
.where(units.unit_id == unit_id, units.place_id == place_id, units.deleted == False) # noqa: E712
select(place_units)
.where(place_units.unit_id == unit_id, place_units.place_id == place_id, place_units.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -183,7 +183,7 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def add_unit(self, cdb: AsyncSession, unit: units) -> ErrorType:
async def add_unit(self, cdb: AsyncSession, unit: place_units) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, unit)
except Exception as ex:
@ -193,17 +193,17 @@ class PlaceCRUD(IPlaceCRUD):
async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]:
"""채널 URL 목록. confirmed_only=True 면 ★ 크롤링 대상(확정된 URL)만."""
try:
conditions = [place_links.place_id == place_id, place_links.deleted == False] # noqa: E712
conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712
if confirmed_only:
conditions.append(place_links.confirmed_at.is_not(None))
query = select(place_links).where(and_(*conditions)).order_by(place_links.discovered_at.asc())
conditions.append(place_channels.confirmed_at.is_not(None))
query = select(place_channels).where(and_(*conditions)).order_by(place_channels.discovered_at.asc())
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def add_link(self, cdb: AsyncSession, link: place_links) -> ErrorType:
async def add_link(self, cdb: AsyncSession, link: place_channels) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, link)
except Exception as ex:
@ -218,12 +218,12 @@ class PlaceCRUD(IPlaceCRUD):
경로를 늘리지 않으려고 일부러 좁게 열어 둔다(collect_service.discover_naver_place)."""
try:
query = (
update(place_links)
update(place_channels)
.where(
place_links.place_id == place_id,
place_links.url == url,
place_links.confirmed_at.is_(None),
place_links.deleted == False, # noqa: E712
place_channels.place_id == place_id,
place_channels.url == url,
place_channels.confirmed_at.is_(None),
place_channels.deleted == False, # noqa: E712
)
.values(confirmed_at=ts, confirmed_by=user_id, updated_at=ts)
)
@ -246,11 +246,11 @@ class PlaceCRUD(IPlaceCRUD):
생성 시점에만 근거로 넘긴다(services/copy_service.py)."""
try:
query = (
update(place_links)
update(place_channels)
.where(
place_links.place_id == place_id,
place_links.url == url,
place_links.deleted == False, # noqa: E712
place_channels.place_id == place_id,
place_channels.url == url,
place_channels.deleted == False, # noqa: E712
)
.values(raw=raw, updated_at=GTime.UTC())
)
@ -263,12 +263,12 @@ class PlaceCRUD(IPlaceCRUD):
"""미확정 링크만 확정한다(이미 확정된 건 rowcount 0 — 동시 처리 가드)."""
try:
query = (
update(place_links)
update(place_channels)
.where(
place_links.link_id == link_id,
place_links.place_id == place_id,
place_links.confirmed_at.is_(None),
place_links.deleted == False, # noqa: E712
place_channels.link_id == link_id,
place_channels.place_id == place_id,
place_channels.confirmed_at.is_(None),
place_channels.deleted == False, # noqa: E712
)
.values(confirmed_at=ts, confirmed_by=user_id, updated_at=ts)
)

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places, publish_logs, site_versions, sites
from common.database.model.models import places, site_publish_logs, site_versions, sites
from common.enums import BuildStatus, ErrorType, SiteStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -54,7 +54,7 @@ class ISiteCRUD(ABC):
pass
@abstractmethod
async def add_log(self, cdb: AsyncSession, log: publish_logs) -> ErrorType:
async def add_log(self, cdb: AsyncSession, log: site_publish_logs) -> ErrorType:
pass
@abstractmethod
@ -214,7 +214,7 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def add_log(self, cdb: AsyncSession, log: publish_logs) -> ErrorType:
async def add_log(self, cdb: AsyncSession, log: site_publish_logs) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, log)
except Exception as ex:
@ -224,9 +224,9 @@ class SiteCRUD(ISiteCRUD):
async def list_logs(self, cdb: AsyncSession, site_id, limit: int = 50) -> Tuple[ErrorType, list]:
try:
query = (
select(publish_logs)
.where(publish_logs.site_id == site_id, publish_logs.deleted == False) # noqa: E712
.order_by(publish_logs.created_at.desc())
select(site_publish_logs)
.where(site_publish_logs.site_id == site_id, site_publish_logs.deleted == False) # noqa: E712
.order_by(site_publish_logs.created_at.desc())
.limit(limit)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)

View File

@ -0,0 +1,50 @@
"""site_sections — **개인화 데이터**의 단일 자리.
★ 규칙(2026-09-09)
area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다. 렌더러 모양 그대로.
site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부 — 거리·숨김·순서·사장님 편집.
★ 이 표는 이미 있었는데 **아무도 읽지 않았다**(실측 2026-09-09: 10행이 마이그레이션 0003 으로
들어간 뒤 방치, 발행 파이프라인은 `sites.theme.sections[].data` 만 봤다). 그 자리를 정본으로
세우면서 CRUD 를 붙인다.
★ 유일성은 `(site_id, section_id)` 다 — 섹션당 한 행. 그래서 upsert 가 갱신을 겸한다.
"""
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import site_sections
from common.utils.gtime import GTime
class SiteSectionCRUD:
async def list_by_site(self, db, site_id):
return await DB_SESSION_MNG.execute(
db,
select(site_sections).where(
site_sections.site_id == site_id,
site_sections.deleted == False, # noqa: E712
).order_by(site_sections.sort_order.asc()),
)
async def upsert(self, db, values: dict):
"""섹션 하나의 개인화 값을 넣거나 갱신한다.
★ `uq_site_contents_section (site_id, section_id) WHERE deleted = false` 에 태운다.
★ source_type 은 갱신하지 않는다 — 사장님이 손으로 고친 섹션(OWNER)을 수집이
API 값으로 되돌리면, 고쳐 둔 것이 다음 수집에 조용히 사라진다.
"""
stmt = pg_insert(site_sections).values(**values)
return await DB_SESSION_MNG.add(
db,
stmt.on_conflict_do_update(
index_elements=[site_sections.site_id, site_sections.section_id],
index_where=(site_sections.deleted == False), # noqa: E712
set_={
"data": stmt.excluded.data,
"shared_ref": stmt.excluded.shared_ref,
"updated_at": GTime.UTC(),
},
),
)

View File

@ -1,10 +1,15 @@
import uuid
from fastapi import APIRouter, Depends, Query
from common.enums import LocalContentStatus
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import RequireOwner, RemoveNoneResponse
from services.local_content_service import LocalContentService
from .protocol import ReqPublishLocalContent, ReqSyncFestivals, ReqUpdateLocalContent, ResLocalContentList, ResSyncFestivals, ResWeather
from .protocol import (
ReqHidePlaceContent, ReqPublishLocalContent, ReqUpdateLocalContent,
ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather,
)
router = APIRouter(prefix="/v1/admin/local-content", tags=["LocalContent"])
weather_router = APIRouter(prefix="/v1/local", tags=["LocalContent"])
@ -20,6 +25,35 @@ async def get_weather(
return RemoveNoneResponse(await service.get_weather(region_code, latitude, longitude))
@weather_router.get("/guide", response_model=ResLocalGuide, summary="업장 주변 가이드(맛집·명소·축제·코스) — 에디터 캔버스용")
async def get_guide(
place_id: uuid.UUID = Query(),
service: LocalContentService = Depends(),
):
"""날씨와 같은 공개 조회다 — 운영자가 숨기지 않은 공공데이터만 나가므로 인증을 요구하지 않는다."""
return RemoveNoneResponse(await service.get_guide(place_id))
@router.get("/place/{place_id}", response_model=ResPlaceContentList, summary="업장 주변정보 목록 (숨김 포함)")
async def list_place_contents(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
return RemoveNoneResponse(await service.list_place_contents(place_id))
@router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)")
async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
"""빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다."""
return RemoveNoneResponse(await service.sync_place_by_id(place_id))
@router.post("/place-content/{place_content_id}/hide", response_model=ResPlaceContentList, summary="주변정보 숨김/해제")
async def hide_place_content(
place_content_id: uuid.UUID, req: ReqHidePlaceContent,
service: LocalContentService = Depends(), _user=Depends(RequireOwner),
):
"""숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다."""
return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden))
@router.get("", response_model=ResLocalContentList)
async def list_contents(
status: LocalContentStatus | None = Query(None), region_code: str | None = Query(None),
@ -28,11 +62,6 @@ async def list_contents(
return RemoveNoneResponse(await service.list(status.value if status else None, region_code))
@router.post("/sync-festivals", response_model=ResSyncFestivals)
async def sync_festivals(req: ReqSyncFestivals, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
return RemoveNoneResponse(await service.sync_festivals(req))
@router.post("/publish", response_model=ResLocalContentList)
async def publish(req: ReqPublishLocalContent, service: LocalContentService = Depends(), user=Depends(RequireOwner)):
return RemoveNoneResponse(await service.publish(req.content_ids, user.user_id))

View File

@ -25,10 +25,29 @@ class LocalContentData(WebPacketProtocol):
display_end_at: datetime | None = None
class ReqSyncFestivals(WebPacketProtocol):
region_code: str = Field(min_length=1, max_length=10)
area_code: str | None = None
start_date: str | None = Field(default=None, pattern=r"^\d{8}$")
class PlaceContentData(WebPacketProtocol):
"""업장 반경 주변정보 1건(admin 목록용)."""
model_config = ConfigDict(from_attributes=True)
place_content_id: uuid.UUID
place_id: uuid.UUID
content_type: LocalContentType
external_id: str
title: str
body: dict[str, Any]
distance_m: int
has_image: bool
hidden: bool
display_end_at: datetime | None = None
collected_at: datetime | None = None
class ResPlaceContentList(Res_WebPacketProtocol):
contents: list[PlaceContentData] = []
class ReqHidePlaceContent(WebPacketProtocol):
hidden: bool
class ReqPublishLocalContent(WebPacketProtocol):
@ -46,9 +65,25 @@ class ResLocalContentList(Res_WebPacketProtocol):
contents: list[LocalContentData] = []
class ResSyncFestivals(Res_WebPacketProtocol):
collected: int = 0
skipped: int = 0
class ResSyncPlace(Res_WebPacketProtocol):
"""업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지."""
festivals: int = 0
attractions: int = 0
restaurants: int = 0
courses: int = 0
changed: bool = False
class ResLocalGuide(Res_WebPacketProtocol):
"""에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다
(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다."""
attractions: list[dict[str, Any]] = []
restaurants: list[dict[str, Any]] = []
festivals: list[dict[str, Any]] = []
courses: list[dict[str, Any]] = []
synced_at: str | None = None
class WeatherData(WebPacketProtocol):

View File

@ -40,6 +40,8 @@ class Req_VerifyPlace(PlaceProtocol):
latitude: Optional[Decimal] = None
longitude: Optional[Decimal] = None
region_code: Optional[str] = None
# 외부 장소 DB 의 분류 문자열(후보의 category_name). 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준으로 박제한다.
category_name: Optional[str] = None
class Req_VerifyPlaceByUrl(PlaceProtocol):

View File

@ -1,5 +1,6 @@
from uuid import UUID
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo
@ -159,6 +160,24 @@ async def start_build(
return RemoveNoneResponse(await service.start_build(user_info, str(place_id), req))
@router.get(
path="/preview",
summary="에디터 미리보기 payload — 발행본과 같은 것",
description="발행이 굽는 것과 **같은 함수**로 만든 SitePayload 를 그대로 준다. "
"미리보기가 이 하나만 먹으면 캔버스와 발행본이 갈릴 자리가 없다. "
"★ DB 도 파일도 건드리지 않는다 — 버전을 만들지 않으므로 눌러도 발행 이력이 안 쌓인다.",
)
async def site_preview(
place_id: UUID,
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
payload = await service.preview_payload(user_info, str(place_id))
if payload is None:
return JSONResponse(status_code=404, content={"detail": "사업장을 찾지 못했습니다"})
return JSONResponse(content=payload)
@router.get(path="/version/list", response_model=Res_SiteVersions, summary="빌드 버전 목록")
async def list_versions(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_versions(user_info, str(place_id)))

View File

@ -0,0 +1,111 @@
"""스키마 마이그레이션 — 이미 만들어진 DB 를 init.sql 최신으로 끌어올린다.
cd solution/backend && .venv/bin/python scripts/migrate.py
cd solution/backend && .venv/bin/python scripts/migrate.py --dry-run
★ 왜 필요한가 (2026-09-09)
`init-data/init.sql` 은 **DB 를 처음 만들 때만** 돈다(postgres 이미지의 초기화 훅).
그래서 파일에 컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다.
실제로 로컬 DB 에 `local.place_contents` 테이블과 `place.places.external_category`
컬럼이 없었고, TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었다.
화면에는 "그냥 안 나오는 것"으로만 보여서 원인을 짚는 데 한참 걸렸다.
DECISIONS.md 가 예고한 그대로다 — "운영 DB 가 생기는 순간 다시 필요해진다".
★ Alembic 을 쓰지 않는다. 이 레포는 ORM 과 init.sql 두 곳에 스키마를 두고
`tests/test_schema_ddl.py` 로 대조하는 구조다. 거기에 세 번째 정의(Alembic 리비전)를
더하면 어긋날 자리가 하나 더 생긴다. 필요한 건 "안 돌린 SQL 을 순서대로 돌린다" 뿐이다.
★ 적용 기록은 `public.schema_migrations` 에 남는다. 이미 있는 번호는 건너뛴다.
파일은 재실행 안전하게 쓰므로(IF NOT EXISTS), 기록이 날아가도 다시 돌리면 그만이다.
"""
import argparse
import asyncio
import os
import sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("APP_ENV", "local")
from sqlalchemy import text # noqa: E402
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
from common.enums import DBWRType # noqa: E402
from common.database.model.models import places # noqa: E402
# parents[3] = 레포 루트 (scripts → backend → solution → 루트).
# ★ test_schema_ddl.py 와 같은 계산이다 — 폴더를 옮기면 둘 다 고친다.
MIGRATIONS_DIR = Path(__file__).resolve().parents[3] / "postgres-init" / "migrations"
_LEDGER_DDL = """
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
def pending(applied: set[str]) -> list[Path]:
"""아직 안 돌린 파일. 파일명 순서가 곧 적용 순서다."""
files = sorted(p for p in MIGRATIONS_DIR.glob("*.sql"))
return [p for p in files if p.stem not in applied]
async def main(dry_run: bool) -> int:
if not MIGRATIONS_DIR.is_dir():
print(f"마이그레이션 폴더가 없습니다: {MIGRATIONS_DIR}")
return 1
db = await DB_SESSION_MNG.start_session(places.DBType(), DBWRType.DB_WRITE.value)
try:
await db.execute(text(_LEDGER_DDL))
await db.commit()
rows = await db.execute(text("SELECT version FROM public.schema_migrations"))
applied = {r[0] for r in rows}
todo = pending(applied)
if not todo:
print(f"적용할 마이그레이션이 없습니다 (적용됨 {len(applied)}건)")
return 0
print(f"적용 대상 {len(todo)}건:")
for path in todo:
print(f" {path.stem}")
if dry_run:
print("\n--dry-run — 아무것도 적용하지 않았습니다.")
return 0
for path in todo:
sql = path.read_text(encoding="utf-8")
print(f"\n▶ {path.stem}")
try:
# ★ 파일 하나를 한 트랜잭션으로 돌린다 — 중간에 실패하면 그 파일은 통째로 되돌아간다.
# 반쯤 적용된 파일이 기록에 남으면 다음 실행이 그것을 건너뛴다.
# ★ asyncpg 드라이버 커넥션으로 직접 보낸다. SQLAlchemy 의 text() 는 prepared
# statement 가 되는데, asyncpg 는 거기에 문장을 여러 개 못 넣는다
# ("cannot insert multiple commands into a prepared statement").
# 마이그레이션 파일은 본래 여러 문장이라 이 경로가 맞다.
raw = await (await db.connection()).get_raw_connection()
await raw.driver_connection.execute(sql)
await db.execute(
text("INSERT INTO public.schema_migrations (version) VALUES (:v)"),
{"v": path.stem},
)
await db.commit()
print(" 적용됨")
except Exception as ex:
await db.rollback()
print(f" 실패 — {ex}")
print(" ★ 여기서 멈춥니다. 뒤 파일은 돌리지 않습니다(순서가 뜻을 갖는다).")
return 1
print(f"\n완료 — {len(todo)}건 적용")
return 0
finally:
await DB_SESSION_MNG.end_session(places.DBType(), DBWRType.DB_WRITE.value)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="적용하지 않고 대상만 출력")
args = parser.parse_args()
raise SystemExit(asyncio.run(main(args.dry_run)))

View File

@ -14,7 +14,7 @@ import uuid
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_links, places, publish_logs, site_versions, sites
from common.database.model.models import place_channels, places, site_publish_logs, site_versions, sites
from common.enums import (
BuildStatus,
DBWRType,
@ -30,6 +30,7 @@ from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail
from services.local_content_service import LocalContentService
from services.site_payload import emit_payload
from services.snapshot import build_snapshot
@ -69,13 +70,13 @@ async def _load_links(place_id: str) -> list:
스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다.
★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s,
select(place_links).where(
place_links.place_id == uuid.UUID(place_id),
place_links.deleted == False, # noqa: E712
select(place_channels).where(
place_channels.place_id == uuid.UUID(place_id),
place_channels.deleted == False, # noqa: E712
),
),
)
@ -84,7 +85,7 @@ async def _load_links(place_id: str) -> list:
async def _log(site_id, version_id, action: PublishAction, result: PublishResult, gate=None, actor=None):
"""발행 시도를 기록한다. 거부됐으면 사유와 상세를 그대로 남긴다 — 운영자가 뭘 고칠지 알아야 한다."""
row = publish_logs(
row = site_publish_logs(
site_id=site_id,
site_version_id=version_id,
action=action.value,
@ -93,7 +94,7 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
detail=(gate.as_log() if gate is not None and not gate.passed else None),
actor_user_id=uuid.UUID(actor) if actor else None,
)
await DB_SESSION_MNG.execute_lambda_run([publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)])
await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)])
async def run_build(job: dict) -> dict:
@ -114,6 +115,17 @@ async def run_build(job: dict) -> dict:
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
site = await ensure_site(place_id)
# ★ 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라
# 이때 받은 값이 실린다. 실패해도 빌드는 계속한다: 곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고,
# place_contents 는 직전 값을 그대로 갖고 있다.
try:
synced = await LocalContentService().sync_place(place)
if not synced.result.success:
LOG.w(f"[build] place={place_id} 주변정보 갱신 건너뜀(직전 값 사용): {synced.msg}")
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
snapshot = await build_snapshot(place)
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
@ -212,7 +224,7 @@ async def run_build(job: dict) -> dict:
# ---- 2차 게이트: 렌더 산출물 기준 ----
# ★ 렌더가 실패했더라도 게이트를 **먼저** 돌린다. 렌더러가 페이지 쓰기를 거부한 이유가
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
# 여기서 사유를 정확히 골라야 site_publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
gate = publish_gate.evaluate(
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches

View File

@ -5,8 +5,8 @@
import uuid
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts as facts_model
from common.database.model.models import media, place_links, places, units
from common.database.model.models import place_facts as facts_model
from common.database.model.models import place_photos, place_channels, places, place_units
from common.enums import DBWRType, ErrorType, LinkChannel, MediaStatus, PlaceCategory, PlaceStatus, SourceType
from common.logger import LOG
from common.utils.gtime import GTime
@ -30,8 +30,8 @@ class CollectAborted(RuntimeError):
async def _add_link(place_id: str, channel, url: str, title: str, discovered_by, raw=None) -> bool:
"""링크 한 건 적재. 이미 있으면 False(유니크 충돌은 재수집의 정상 경로다)."""
err = await DB_SESSION_MNG.execute_lambda_run(
[place_links.DBType()],
[lambda s: _place_crud.add_link(s, place_links(
[place_channels.DBType()],
[lambda s: _place_crud.add_link(s, place_channels(
place_id=uuid.UUID(place_id), channel=channel.value, url=url, title=title,
discovered_by=discovered_by.value, discovered_at=GTime.UTC(), raw=raw,
))],
@ -82,7 +82,7 @@ async def discover_naver_place(place, place_id: str) -> str:
# 근거가 사람 확인과 같은 수준이므로 클릭을 한 번 더 받는 것은 이득 없이 막기만 한다 —
# 실제로 이 클릭 때문에 힐튼·도플로가 "수집했는데 0건"으로 끝났다.
err, _rows = await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
)
LOG.i(f"[collect] 네이버 플레이스 링크 {'등록·확정' if added else '확정'} — place {place_id_naver}")
@ -128,7 +128,7 @@ async def discover_tour_api(place, place_id: str) -> str:
place_id, LinkChannel.ETC, url, f"{place.name} 한국관광공사 TourAPI", SourceType.API,
)
err, _rows = await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
)
LOG.i(f"[collect] TourAPI 링크 {'등록·확정' if added else '확정'} — contentId {content_id}")
@ -136,7 +136,7 @@ async def discover_tour_api(place, place_id: str) -> str:
async def discover_links(place, place_id: str, *, include_perplexity: bool = False) -> dict:
"""채널 URL 을 찾아 place_links 에 적재한다(미확정 상태).
"""채널 URL 을 찾아 place_channels 에 적재한다(미확정 상태).
★ 순서가 곧 신뢰도다. 지금 자동 발견 경로는 **네이버 플레이스 직접 해석 하나뿐**이고,
Perplexity 는 사용자가 추가 채널 탐색 옵션을 고른 회차에만 실행한다.
@ -198,7 +198,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
stat["filtered_out"] = found.reason_counts()
now = GTime.UTC()
for link in found.links:
row = place_links(
row = place_channels(
place_id=uuid.UUID(place_id),
channel=link.channel.value,
url=link.url,
@ -208,7 +208,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
raw=found.raw, # 본문 + search_results 원문 — 환각 추적용
)
err = await DB_SESSION_MNG.execute_lambda_run(
[place_links.DBType()],
[place_channels.DBType()],
[lambda s, r=row: _place_crud.add_link(s, r)],
)
if err == ErrorType.SUCCESS:
@ -231,7 +231,7 @@ async def confirm_targets(place, place_id: str, only_link_ids: list[str] | None)
"""
stat = {"confirmed": 0, "already": 0, "unsupported": 0}
err, links = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_links(s, uuid.UUID(place_id), False),
)
@ -253,7 +253,7 @@ async def confirm_targets(place, place_id: str, only_link_ids: list[str] | None)
targets.append(link)
continue
_e, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda s, l=link: _place_crud.confirm_link(s, uuid.UUID(place_id), l.link_id, place.verified_by, now),
)
if rowcount:
@ -322,7 +322,7 @@ async def ensure_units(place_id: str, sources: list) -> dict:
return {}
err, rows = await DB_SESSION_MNG.execute_lambda(
units.DBType(),
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_units(s, uuid.UUID(place_id)),
)
@ -332,9 +332,9 @@ async def ensure_units(place_id: str, sources: list) -> dict:
for order, name in enumerate(names):
if name in existing:
continue
row = units(place_id=uuid.UUID(place_id), name=name, sort_order=order)
row = place_units(place_id=uuid.UUID(place_id), name=name, sort_order=order)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[units.DBType()],
[place_units.DBType()],
[lambda s, r=row: _place_crud.add_unit(s, r)],
)
if run_err == ErrorType.SUCCESS:
@ -391,10 +391,10 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
from sqlalchemy import select
err, rows = await DB_SESSION_MNG.execute_lambda(
media.DBType(),
place_photos.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s, select(media).where(media.place_id == uuid.UUID(place_id), media.deleted == False) # noqa: E712
s, select(place_photos).where(place_photos.place_id == uuid.UUID(place_id), place_photos.deleted == False) # noqa: E712
),
)
seen = {r.origin_url for r in (rows or []) if r.origin_url} if err == ErrorType.SUCCESS else set()
@ -405,7 +405,7 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
if cm.origin_url in seen:
stat["skipped_duplicate"] += 1
continue
row = media(
row = place_photos(
place_id=uuid.UUID(place_id),
unit_id=unit_map.get(cm.unit_name),
url=cm.origin_url, # Vision·재게시 결론 전까지는 원본 URL 을 그대로 둔다
@ -417,7 +417,7 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
sort_order=order,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[media.DBType()],
[place_photos.DBType()],
[lambda s, r=row: DB_SESSION_MNG.insert(s, r, raise_error=False)],
)
if run_err == ErrorType.SUCCESS:
@ -515,7 +515,7 @@ async def run_collect(job: dict) -> dict:
# intro 같은 allow_llm 필드에 원문을 넣으면 발행본이 원문으로 덮인다(2026-08-31 사고).
if (source.text or "").strip():
await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda sess, u=link.url, t=source.text: _place_crud.set_link_raw(
sess, uuid.UUID(place_id), u, {"text": t[:8000]},
),
@ -545,6 +545,13 @@ async def run_collect(job: dict) -> dict:
if result["media"]["stored"] > 0:
result["vision_job_id"] = await _enqueue_vision(place_id, owner_user_id)
# ★ 지역 데이터(주변 맛집·관광지·축제 + 지역 이야기)를 **여기서** 건다.
# 수집이 끝난 시점이 좌표·행정구역이 확정되는 가장 이른 자리다. 사장님이 템플릿을 고르는
# 동안(Step4) 백그라운드로 돌아, 생성 단계(Step5)에 닿을 즈음이면 대개 끝나 있다 —
# 전에는 에디터에 들어간 뒤에야 시작해서 첫 화면이 늘 절반만 그려졌다.
from services import story_service
result["local_job_id"] = await story_service.enqueue_region_job(place)
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
return result
@ -577,7 +584,7 @@ async def _store_booking_link(place, place_id: str, source) -> bool:
f"{place.name} 네이버 예약", SourceType.CRAWL,
)
await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
)
LOG.i(f"[collect] 네이버 예약 링크 {'등록·확정' if added else '확정'} — {url}")

View File

@ -276,7 +276,7 @@ class TourApiAdapter:
사장님 사이트의 '숙소 소개' 를 차지하고, 정작 Gemini 가 쓴 162자 소개문은
PENDING_OWNER 로 뒤에 밀렸다. 원문을 그대로 싣는 것은 GEO 관점에서도 복제 콘텐츠다.
원문은 버리지 않는다 — RawSource.text 로 나가 place_links.raw 에 박제되고,
원문은 버리지 않는다 — RawSource.text 로 나가 place_channels.raw 에 박제되고,
소개문·FAQ 생성 시점에만 근거로 쓰인다(services/copy_service.py).
"""
return []
@ -284,9 +284,11 @@ class TourApiAdapter:
def _lodging_facts(self, intro: dict) -> list[CollectedFact]:
"""숙박 detailIntro2 → 사업장 단위 fact.
★ 스키마에 없는 값은 만들지 않는다. roomcount·scalelodging·foodplace 는
lodging 스키마에 자리가 없어서 **일부러 버린다** — 억지로 다른 key 에 넣으면
'식음료장 있음' 이 '조식 제공' 으로 둔갑한다.
★ 스키마에 없는 값은 만들지 않는다. foodplace('식음료장 있음')는 lodging 스키마에
자리가 없어서 **일부러 버린다** — 억지로 breakfast 에 넣으면 '조식 제공' 으로 둔갑한다.
★ roomcount·scalelodging·accomcountlodging·subfacility 는 2026-09-07 에 자리를 만들었다
(total_rooms·building_scale·accommodation_capacity·facilities). 실측(오블로모프 3103191)에서
TourAPI 가 준 값의 절반이 자리가 없어 버려지고 있었다.
"""
return self._collect([
("check_in_time", self._plain(intro.get("checkintime"))[:40]),
@ -296,6 +298,11 @@ class TourApiAdapter:
("parking", self._bool_str(self._head_bool(intro.get("parkinglodging")))),
("pickup_service", self._bool_str(self._head_bool(intro.get("pickup")))),
("bbq_available", self._bool_str(self._yn(intro.get("barbecue")))),
("facilities", self._plain(intro.get("subfacility"))[:500]),
("total_rooms", self._number(intro.get("roomcount"))),
("accommodation_capacity", self._number(intro.get("accomcountlodging"))),
# "약 23평" · "대지 면적 11,570㎡" 처럼 단위가 제각각이라 숫자로 뽑지 않고 원문을 싣는다.
("building_scale", self._plain(intro.get("scalelodging"))[:200]),
])
def _restaurant_facts(self, intro: dict) -> list[CollectedFact]:
@ -362,6 +369,16 @@ class TourApiAdapter:
("peak_price", self._number(row.get("roompeakseasonminfee1")), name),
("has_kitchen", self._bool_str(self._yn(row.get("roomcook"))), name),
("has_aircon", self._bool_str(self._yn(row.get("roomaircondition"))), name),
# 객실 편의시설 Y/N. ★ 빈 값은 '없음'이 아니라 '모름'이다 — _yn 이 None 을 주면 fact 를 만들지 않는다.
("has_bathroom", self._bool_str(self._yn(row.get("roombathfacility"))), name),
("has_tv", self._bool_str(self._yn(row.get("roomtv"))), name),
("has_internet", self._bool_str(self._yn(row.get("roominternet"))), name),
("has_refrigerator", self._bool_str(self._yn(row.get("roomrefrigerator"))), name),
("has_hairdryer", self._bool_str(self._yn(row.get("roomhairdryer"))), name),
("has_toiletries", self._bool_str(self._yn(row.get("roomtoiletries"))), name),
("has_table", self._bool_str(self._yn(row.get("roomtable"))), name),
("has_sofa", self._bool_str(self._yn(row.get("roomsofa"))), name),
("has_home_theater", self._bool_str(self._yn(row.get("roomhometheater"))), name),
])
return facts

View File

@ -10,7 +10,7 @@ import uuid
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts, faqs, place_links, places, units
from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
@ -64,7 +64,7 @@ async def run_copy(job: dict) -> dict:
# ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다.
pid = uuid.UUID(place_id)
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
)
@ -81,7 +81,7 @@ async def run_copy(job: dict) -> dict:
for r in fact_rows
if r.unit_id is None and (r.value or "").strip()
]
# ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_links.raw 에 박제된 글이다.
# ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_channels.raw 에 박제된 글이다.
#
# 왜 필요한가: 소개 원문(TourAPI overview·네이버 description)에만 있는 정보가 있다.
# '전면 통창 실내 온수풀', '판교역에서 3분' 같은 것들인데, 이게 근거에 없으면
@ -91,7 +91,7 @@ async def run_copy(job: dict) -> dict:
# 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31).
# 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다.
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_links(s, pid, True),
)
@ -110,7 +110,7 @@ async def run_copy(job: dict) -> dict:
# 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도
# "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다.
u_err, unit_rows = await DB_SESSION_MNG.execute_lambda(
units.DBType(),
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_units(s, pid),
)
@ -196,7 +196,7 @@ async def run_copy(job: dict) -> dict:
# 확인 안 된 기존 생성 FAQ 는 내리고 새로 넣는다. 사람이 확인한 FAQ 는 건드리지 않는다.
await DB_SESSION_MNG.execute_lambda_claim(
faqs.DBType(),
place_faqs.DBType(),
lambda s: _faq_crud.expire_generated(s, pid, now),
)
for order, faq in enumerate(copy.faqs or []):
@ -204,7 +204,7 @@ async def run_copy(job: dict) -> dict:
# ★ 근거 없는 FAQ 는 저장하지 않는다.
stat["rejected"].append([faq.question, "근거 fact 없음"])
continue
row = faqs(
row = place_faqs(
place_id=pid,
question=faq.question,
answer=faq.answer,
@ -214,7 +214,7 @@ async def run_copy(job: dict) -> dict:
sort_order=order,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[faqs.DBType()],
[place_faqs.DBType()],
[lambda s, r=row: _faq_crud.add_faq(s, r)],
)
if run_err == ErrorType.SUCCESS:

View File

@ -43,9 +43,10 @@ _BASE_URL = "https://dapi.kakao.com"
_KEYWORD_URL = f"{_BASE_URL}/v2/local/search/keyword.json"
_COORD2REGION_URL = f"{_BASE_URL}/v2/local/geo/coord2regioncode.json"
_CATEGORY_URL = f"{_BASE_URL}/v2/local/search/category.json"
_ADDRESS_URL = f"{_BASE_URL}/v2/local/search/address.json"
# 초과 단가(원). 로그에 함께 남겨 어떤 호출이 비싼지 바로 보이게 한다.
_UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5}
_UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5, "address": 0.5}
# 프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거.
_CALL_COUNTS: Counter = Counter()
@ -362,6 +363,28 @@ class KakaoLocalClient:
picked = next((d for d in docs if d.get("region_type") == "H"), docs[0])
return RegionCode.from_document(picked)
# ---- 2-1) 주소 → 좌표 ----
async def geocode_address(self, address: str) -> Optional[tuple[float, float]]:
"""도로명·지번 주소 → (위도, 경도). 결과가 없으면 None — 좌표를 지어내지 않는다.
★ 쓰는 곳: 좌표 없이 검증된 사업장의 주변 정보 수집(local_content_service.sync_place).
동일 업소 검증(카카오 후보·네이버 상세)은 좌표를 같이 주므로 보통은 비어 있지 않다 —
비는 건 옛 데이터나 좌표 없는 후보를 고른 경우다. 그때 주소로 한 번 더 찾는다.
싸다(초과 시 건당 0.5원). 결과는 places 에 박제하므로 사업장당 1회다.
"""
query = (address or "").strip()
if not query:
return None
data = await self._get(_ADDRESS_URL, {"query": query, "size": "1"}, "address")
docs = data.get("documents") or []
if not docs:
LOG.i(f"[kakao] 주소 → 좌표 결과 없음: {query[:60]}")
return None
lat, lon = _to_float(docs[0].get("y")), _to_float(docs[0].get("x")) # ★ y=위도 · x=경도
if lat is None or lon is None:
return None
return lat, lon
# ---- 3) 주변 맛집·시설 ----
async def search_category(
self,

View File

@ -52,7 +52,7 @@ class ChannelDiscovery:
"""채널 발견 결과.
links : 상세 페이지로 판정돼 살아남은 후보 URL. 동일 업소 검증을 통과해야 크롤링 대상이 된다
raw : 응답 원문(본문 + search_results). place_links.raw 에 통째로 박제한다
raw : 응답 원문(본문 + search_results). place_channels.raw 에 통째로 박제한다
search_count : 이번 호출에서 발생한 검색 횟수 — **검색 요금이 토큰 요금과 별도**라 추적한다
filtered_out : 걸러낸 URL 과 사유 [(url, reason), ...].
★ 조용히 버리지 않는다 — 운영자가 "왜 이 URL 이 빠졌나"를 볼 수 있어야
@ -84,7 +84,7 @@ async def discover_channels(
"""상호명으로 채널 URL 후보를 찾는다. **URL 발견 전용 — 답변을 사실로 쓰지 마라.**
돌려주는 URL 은 아직 '이 가게의 것'이라는 보장이 없다. 동일 업소 검증을 통과해
확정(place_links.confirmed_at)된 URL 만 크롤링 대상이 된다.
확정(place_channels.confirmed_at)된 URL 만 크롤링 대상이 된다.
발견된 URL 중 **상세 페이지가 아닌 것(루트·목록·SEO 랜딩)과 블로그는 걸러낸다.**
걸러낸 목록은 `filtered_out` 에 사유와 함께 남는다 — 조용히 버리지 않는다.

View File

@ -1,17 +1,58 @@
"""공공데이터포털 전국문화축제표준데이터 클라이언트.
"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트.
설정 키 이름은 기존 배포 계약을 유지하기 위해 TOUR_API_KEY를 그대로 사용한다.
collector/tour_api_adapter.py 가 '사업장 1곳'의 fact 를 캐는 쪽이라면, 여기는
업장 좌표 반경 안의 곁들이 정보(맛집·관광지·축제·여행코스)를 긁는 쪽이다.
결과는 place_contents(업장 단위 캐시)에 들어가 발행본·캔버스의 지역 정보 섹션이 된다.
★ 왜 행정구역이 아니라 좌표인가 (2026-09-04, specs/2026-09-04-tourapi-radius-spike.md)
처음엔 법정동 코드로 areaBasedList2 를 불렀다. 그건 '그 시군구에 있는 것'이지
'이 업장에서 가까운 것'이 아니다 — 양양군 업장 옆 5km 속초 관광지가 빠지고,
같은 시군구 반대편 30km 맛집이 붙는다. locationBasedList2 는 좌표+반경으로 묻고
거리(dist)까지 준다. 실측(군산 절골길 18): 10km 안 133건, 5km 안 100건.
★ 호출 수 — 업장당 종류별 1회 (맛집·관광지·축제)
2026-09-08 부터 종류마다 **따로** 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 반경 없음) —
한 걸음에 갈 맛집과 차 타고 갈 축제를 같은 반경으로 재는 게 맞지 않았다.
여행코스(25)는 실측 결과 반경을 넓혀도 데이터가 거의 없어(전북 전체 3건) 뺐다.
★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다 (2026-09-08 교체)
locationBasedList2(contentTypeId=15) 의 위치 색인은 못 믿는다 — 실측(군산 절골길 18):
반경 20km 를 아무리 넓혀도 2023년에 끝난 서천 전시 1건만 나오고, 코앞 500m 의
진행 예정 축제(군산시간여행축제 등)는 끝내 안 잡혔다. searchFestival2 는 법정동(시도)
단위로 묻지만 정확하고 기간까지 함께 준다 — 그래서 시도 전체를 받아 우리가 거리로 거른다.
eventStartDate 는 파라미터로 준 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직
진행 중인 행사는 잡히지 않는다 — 실측). 그래서 항상 **그 해 1월 1일**로 고정해 부르고,
이미 끝난 행사(eventenddate < 오늘)만 우리가 한 번 더 거른다.
★ 이미지 저작권 — 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 와 같은 규칙)
firstimage 는 공공누리 Type1(출처표시)·Type3(출처표시+변경금지)만 남긴다.
발행본은 상업적 이용이라 Type2·Type4 는 싣지 못한다. 유형을 모르면 버린다.
"""
import hashlib
from datetime import date
from urllib.parse import unquote
from typing import Optional
from urllib.parse import unquote, urlencode
import httpx
from common.enums import LocalContentType
from common.utils.geo import haversine_m
from config.server_configs import external_api_config
_URL = "https://api.data.go.kr/openapi/tn_pubr_public_cltur_fstvl_api"
BASE_URL = "https://apis.data.go.kr/B551011/KorService2"
REQUEST_TIMEOUT = 25
PAGE_SIZE = 100
# 반경 10km 도심은 300건을 넘지 않는다(실측 133건). 그 이상은 어차피 종류별 20건 상한에 안 든다.
MAX_PAGES = 4
# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용). 축제(15)는 여기 없다 —
# searchFestival2 로 따로 받는다(위 모듈 docstring 참고). 여행코스(25)도 뺐다(데이터 부족).
CONTENT_TYPE_MAP = {
"39": LocalContentType.RESTAURANT.value,
"12": LocalContentType.ATTRACTION.value,
}
# 상업적 이용이 허용된 공공누리 유형(tour_api_adapter 와 동일 규칙·동일 이유).
_COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"})
class TourApiNotConfigured(RuntimeError):
@ -22,52 +63,249 @@ class TourApiRequestFailed(RuntimeError):
pass
def _normalize(item: dict) -> dict:
"""표준데이터 필드명을 화면/저장소의 공통 축제 필드로 변환한다."""
identity = "|".join(str(item.get(key) or "") for key in ("fstvlNm", "fstvlStartDate", "fstvlEndDate", "insttCode"))
return {
"contentid": hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32],
"title": item.get("fstvlNm"),
"eventstartdate": str(item.get("fstvlStartDate") or "").replace("-", ""),
"eventenddate": str(item.get("fstvlEndDate") or "").replace("-", ""),
"addr1": item.get("rdnmadr") or item.get("lnmadr") or item.get("eventPlace"),
"mapx": item.get("longitude"),
"mapy": item.get("latitude"),
"tel": item.get("phoneNumber"),
"homepage": item.get("homepageUrl"),
"organizer": item.get("suprtInsttNm"),
"overview": item.get("relateInfo") or item.get("opar"),
"raw": item,
}
# ── HTTP ───────────────────────────────────────────────────────────────
async def search_festivals(*, start_date: date, area_code: str | None = None, rows: int = 100) -> list[dict]:
def _service_key() -> str:
key = (external_api_config.tour_api_key or "").strip()
if not key:
raise TourApiNotConfigured("TOUR_API_KEY is not configured")
params = {
# 포털의 "Encoding" 키를 넣어도 httpx가 이중 인코딩하지 않도록 원문으로 되돌린다.
"serviceKey": unquote(key),
"type": "json",
"numOfRows": max(1, min(rows, 1000)),
"pageNo": 1,
}
return key
async def _call(client: httpx.AsyncClient, op: str, **params) -> tuple[list[dict], int]:
"""오퍼레이션 1회 → (항목, totalCount). 결과 없음은 빈 목록 — 없는 것과 실패를 구분한다.
★ 포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다
(collector/tour_api_adapter._call 과 같은 처리).
"""
query = urlencode(
{"serviceKey": unquote(_service_key()), "MobileOS": "ETC", "MobileApp": "o2o-web4ai",
"_type": "json", "numOfRows": str(PAGE_SIZE), "pageNo": "1", **params},
safe="",
)
res = await client.get(f"{BASE_URL}/{op}?{query}")
if res.status_code != 200:
raise TourApiRequestFailed(f"{op} HTTP {res.status_code}")
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=5.0)) as client:
response = await client.get(_URL, params=params)
response.raise_for_status()
payload = response.json()
response_data = payload.get("response", payload)
header = response_data.get("header", {})
if header.get("resultCode") not in (None, "00", "0000"):
raise TourApiRequestFailed(header.get("resultMsg") or "TourAPI returned an error")
items_node = response_data.get("body", {}).get("items", [])
items = items_node.get("item", []) if isinstance(items_node, dict) else items_node
if isinstance(items, dict):
items = [items]
normalized = [_normalize(item) for item in (items or [])]
# API가 날짜 필터를 제공하지 않으므로 종료된 축제는 애플리케이션에서 제외한다.
cutoff = start_date.strftime("%Y%m%d")
return [item for item in normalized if not item["eventenddate"] or item["eventenddate"] >= cutoff]
except (httpx.HTTPError, ValueError, TypeError) as ex:
raise TourApiRequestFailed(str(ex)) from ex
payload = res.json()
except ValueError:
# 인증 실패·쿼터 초과는 XML 로 온다. 본문 앞부분을 그대로 올려 원인을 감추지 않는다.
raise TourApiRequestFailed(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}")
# 게이트웨이 오류(미등록 키 등)는 200 + JSON 이지만 response 가 없다. 그것도 원인을 드러낸다.
if "response" not in payload:
raise TourApiRequestFailed(f"{op} 게이트웨이 오류: {str(payload)[:160]}")
header = payload["response"].get("header", {})
code = str(header.get("resultCode") or "")
if code not in ("0000", "00"):
raise TourApiRequestFailed(f"{op} 실패 [{code}] {header.get('resultMsg')}")
body = payload["response"].get("body", {}) or {}
items = (body.get("items") or {}).get("item") if isinstance(body.get("items"), dict) else None
if isinstance(items, dict):
items = [items]
total = int(body.get("totalCount") or 0)
return items or [], total
# ── 정규화 ──────────────────────────────────────────────────────────────
def _int(value) -> Optional[int]:
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return None
def _normalize(item: dict) -> Optional[dict]:
"""locationBasedList2 항목 1건 → 정규화 dict. contentid·title·거리·종류 없으면 버린다.
★ 여기서 **렌더러가 읽는 이름**으로 바꾼다(`name`·`location`·`imageUrl`). 예전에는 TourAPI
원문 이름(`title`·`addr1`·`firstimage`)을 그대로 저장하고 빌드마다 바꿔 실었다 —
같은 변환을 발행할 때마다 다시 하는 셈이었고, 캔버스와 발행본이 각자 바꾸면 갈릴 자리였다.
★ 저장 자리가 갈리는 값은 여기서 **평평하게** 내보내기만 한다. 어느 컬럼·어느 테이블로
가는지는 부르는 쪽(local_content_service.sync_place)이 정한다:
distance_m → 사이트 개인화(site_sections) 좌표 → area_contents 컬럼
"""
content_id = str(item.get("contentid") or "").strip()
title = str(item.get("title") or "").strip()
kind = CONTENT_TYPE_MAP.get(str(item.get("contenttypeid") or "").strip())
distance = _int(item.get("dist"))
if not content_id or not title or kind is None or distance is None:
return None
out = {
"contentid": content_id, "content_type": kind, "distance_m": distance,
# 렌더러 계약(LocalPlace). searchQuery 는 이름 그대로다 — 우리가 URL 을 지어내지 않는다.
"name": title, "searchQuery": title,
}
address = str(item.get("addr1") or "").strip()
if address:
out["location"] = address
# 좌표는 컬럼으로 간다. mapX=경도 · mapY=위도 (뒤집으면 엉뚱한 지역이 붙는다).
for src, dst in (("mapx", "longitude"), ("mapy", "latitude")):
value = str(item.get(src) or "").strip()
if value:
out[dst] = value
# ★ 중분류만 남긴다. 렌더러는 안 쓰지만 서버가 주변 맛집에서 같은 업태(경쟁 업소)를 뺄 때 쓴다.
cls = str(item.get("lclsSystm2") or "").strip()
if cls:
out["lclsSystm2"] = cls
# 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다. 유형을 모르면 버린다.
image = str(item.get("firstimage") or "").strip()
license_code = str(item.get("cpyrhtDivCd") or "").strip().lower()
if image and license_code in _COMMERCIAL_OK_LICENSES:
out["imageUrl"] = image
return out
# ── 공개 API ────────────────────────────────────────────────────────────
def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT)
async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float,
*, radius_m: int, content_type_id: str) -> list[dict]:
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.
★ mapX=경도 · mapY=위도. 뒤집으면 엉뚱한 지역이 붙는다(카카오와 같은 함정).
"""
out: list[dict] = []
seen: set[str] = set()
for page in range(1, MAX_PAGES + 1):
items, total = await _call(
client, "locationBasedList2",
mapX=str(longitude), mapY=str(latitude), radius=str(radius_m),
contentTypeId=content_type_id, arrange="E", pageNo=str(page),
)
for item in items:
body = _normalize(item)
if body and body["contentid"] not in seen:
seen.add(body["contentid"])
out.append(body)
if not items or page * PAGE_SIZE >= total:
break
out.sort(key=lambda b: b["distance_m"])
return out
def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]:
"""searchFestival2 항목 1건 → 정규화 dict. locationBasedList2 와 달리 `dist` 를 안 주므로
(호출측이 haversine 으로 잰 값을) 그대로 받는다.
★ `_normalize` 와 같은 규약이다 — 렌더러 이름으로 바꿔 내보내고, 저장 자리는 부르는 쪽이 정한다.
★ 기간(eventstartdate/enddate)은 **원값 그대로** 남긴다. 화면 문자열("2026.10.01 ~ …")로 미리
구워 두면 노출 기간 필터(`_festival_not_ended`·display_end_at)가 읽을 값이 없어진다.
날짜는 사실이고 문장은 표기다 — 사실만 저장한다.
"""
content_id = str(item.get("contentid") or "").strip()
title = str(item.get("title") or "").strip()
if not content_id or not title:
return None
out = {
"contentid": content_id, "content_type": LocalContentType.FESTIVAL.value,
"distance_m": distance_m, "name": title, "searchQuery": title,
}
address = str(item.get("addr1") or "").strip()
if address:
out["location"] = address
for src, dst in (("mapx", "longitude"), ("mapy", "latitude")):
value = str(item.get(src) or "").strip()
if value:
out[dst] = value
for key in ("eventstartdate", "eventenddate", "homepage", "overview", "lclsSystm2"):
value = str(item.get(key) or "").strip()
if value:
out[key] = value
image = str(item.get("firstimage") or "").strip()
license_code = str(item.get("cpyrhtDivCd") or "").strip().lower()
if image and license_code in _COMMERCIAL_OK_LICENSES:
out["imageUrl"] = image
return out
def _festival_not_ended(body: dict, today: date) -> bool:
"""종료일이 지났으면 끝난 축제 — 신지 않는다. 기간을 아예 모르면 못 믿으니 역시 뺀다.
종료일 없이 시작일만 있으면(무기한 진행) 유지한다 — 끝났다는 증거가 없다."""
end, start = body.get("eventenddate"), body.get("eventstartdate")
ymd = today.strftime("%Y%m%d")
if end:
return len(end) == 8 and end.isdigit() and end >= ymd
return bool(start)
async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float,
*, sido_code: str, today: date) -> list[dict]:
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 이미 끝난 것 제외). 반경으로 자르지 않는다.
★ 반경을 안 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 로 자르면 시도 안의
큰 축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다.
(종류별 노출 상한은 스냅샷이 20건으로 자른다 — 사진 있는 것 우선 → 가까운 순.)
★ locationBasedList2 의 위치 색인은 못 믿어서 searchFestival2 를 쓴다(위 모듈 docstring).
eventStartDate 는 그 해 1월 1일로 **고정** — "오늘" 을 넣으면 그 이전에 시작해 아직 진행 중인
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받고, 끝난 것만 여기서 거른다.
★ 좌표 없는 항목은 뺀다 — distance_m 이 NOT NULL 이고, 거리 없는 카드는 도보 필터에 못 얹는다.
"""
start_date = date(today.year, 1, 1).strftime("%Y%m%d")
out: list[dict] = []
seen: set[str] = set()
for page in range(1, MAX_PAGES + 1):
items, total = await _call(
client, "searchFestival2",
eventStartDate=start_date, lDongRegnCd=sido_code, pageNo=str(page),
)
for item in items:
lnglat = _mapxy(item) # (경도, 위도) — 좌표가 없으면 거리를 잴 수 없다
if lnglat is None:
continue
lng, lat = lnglat
distance = haversine_m(latitude, longitude, lat, lng) # 자르지 않는다 — 정렬·표시용
body = _normalize_festival(item, round(distance))
if not body or body["contentid"] in seen:
continue
if not _festival_not_ended(body, today):
continue
seen.add(body["contentid"])
out.append(body)
if not items or page * PAGE_SIZE >= total:
break
out.sort(key=lambda b: b["distance_m"])
return out
def _mapxy(item: dict) -> Optional[tuple[float, float]]:
"""(경도, 위도). 좌표가 없거나 숫자가 아니면 None — 거리를 잴 수 없는 항목은 반경으로 못 거른다."""
try:
return float(item.get("mapx")), float(item.get("mapy"))
except (TypeError, ValueError):
return None
async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Optional[str]:
"""콘텐츠 1건의 중분류 코드(lclsSystm2). 못 구하면 None.
업장 자신이 TourAPI 에 등록돼 있을 때(place_channels 의 tour:// 링크) 그 업장의 업태를 여기서 읽는다 —
주변 맛집에서 같은 중분류를 빼기 위해서다. 외부 분류 문자열 매핑보다 이 값이 우선이다(같은 체계라 오차가 없다).
"""
items, _ = await _call(client, "detailCommon2", contentId=content_id)
if not items:
return None
code = str(items[0].get("lclsSystm2") or "").strip()
return code or None
def festival_is_current(period: Optional[tuple[str, str]], today: date) -> bool:
"""종료일이 지났으면 끝난 축제 — 싣지 않는다. 기간을 아예 모르면(None) 못 믿으니 역시 뺀다.
종료일 없이 시작일만 있으면(무기한 진행) 시작일이 지났어도 유지한다 — 끝났다는 증거가 없다."""
if not period:
return False
start, end = period
ymd = today.strftime("%Y%m%d")
if end:
return len(end) == 8 and end.isdigit() and end >= ymd
return bool(start)

View File

@ -4,7 +4,7 @@ from fastapi import Depends
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts, places, units
from common.database.model.models import place_facts, places, place_units
from common.enums import (
FACT_STATUS_TRANSITIONS,
PUBLISHABLE_FACT_STATUSES,
@ -121,7 +121,7 @@ class FactService:
return res
list_err, rows = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_facts(s, uuid.UUID(place_id), unit_id, None, publishable_only, True),
)
@ -220,7 +220,7 @@ class FactService:
async def _unit_map(self, place_id: str) -> dict:
"""단위 이름 → unit_id. 붙여넣기 값이 어느 객실 것인지 잇는 데만 쓴다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
units.DBType(),
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: self.place_crud.list_units(s, uuid.UUID(place_id)),
)
@ -264,7 +264,7 @@ class FactService:
pid = uuid.UUID(place_id)
pub_err, published = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_published_fact(s, pid, req.unit_id, req.key),
)
@ -278,7 +278,7 @@ class FactService:
# ── 값이 그대로다 — 검증을 초기화하지 않고 '언제 다시 확인했는지'만 갱신 ──
if same_value:
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
facts.DBType(),
place_facts.DBType(),
lambda s: self.crud.refresh_collected(s, published.fact_id, req.source_type.value, req.source_url, now),
)
if run_err != ErrorType.SUCCESS:
@ -299,7 +299,7 @@ class FactService:
target_status = FactStatus.PENDING_OWNER if published is not None else FactStatus.UNVERIFIED
cand_err, candidate = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_candidate(s, pid, req.unit_id, req.key, req.source_type.value),
)
@ -310,7 +310,7 @@ class FactService:
# 같은 출처가 이미 올려둔 후보가 있으면 갱신한다(같은 후보가 계속 쌓이지 않게).
if candidate is not None:
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
facts.DBType(),
place_facts.DBType(),
lambda s: self.crud.update_candidate(
s, candidate.fact_id, req.value, req.source_url, target_status.value, now
),
@ -321,7 +321,7 @@ class FactService:
res.outcome = FactWriteOutcome.CANDIDATE_UPDATED
return await self._reload(res, pid, candidate.fact_id)
fact = facts(
fact = place_facts(
place_id=pid,
unit_id=req.unit_id,
key=req.key,
@ -334,7 +334,7 @@ class FactService:
expires_at=req.expires_at,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[facts.DBType()],
[place_facts.DBType()],
[lambda s: self.crud.add_fact(s, fact)],
)
if run_err != ErrorType.SUCCESS:
@ -349,7 +349,7 @@ class FactService:
사람이 넣은 값은 그 사람이 곧 출처이자 책임 주체라 별도 확인 단계를 두지 않는다.
기존 노출값은 지우지 않고 EXPIRED 이력으로 남긴다."""
fact = facts(
fact = place_facts(
place_id=pid,
unit_id=req.unit_id,
key=req.key,
@ -364,7 +364,7 @@ class FactService:
expires_at=req.expires_at,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[facts.DBType()],
[place_facts.DBType()],
[
lambda s: self._expire_then_ok(s, pid, req, now),
lambda s: self.crud.add_fact(s, fact),
@ -387,7 +387,7 @@ class FactService:
async def _reload(self, res, pid, fact_id):
_e, row = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_fact(s, pid, fact_id),
)
@ -409,7 +409,7 @@ class FactService:
pid = uuid.UUID(place_id)
fid = uuid.UUID(fact_id)
get_err, fact = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_fact(s, pid, fid),
)
@ -445,7 +445,7 @@ class FactService:
if promoting:
funcs.append(lambda s: self._reject_others_ok(s, pid, fact, now, fid))
run_err = await DB_SESSION_MNG.execute_lambda_run([facts.DBType()], funcs)
run_err = await DB_SESSION_MNG.execute_lambda_run([place_facts.DBType()], funcs)
if run_err != ErrorType.SUCCESS:
res.result.SetResult(
ErrorType.FACT_INVALID_TRANSITION if run_err == ErrorType.DB_EMPTY_DATA else run_err

View File

@ -15,7 +15,7 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import faqs, places
from common.database.model.models import place_faqs, places
from common.enums import (
CANDIDATE_FACT_STATUSES,
FACT_STATUS_TRANSITIONS,
@ -67,7 +67,7 @@ class FaqService:
async def _reload(self, res: Res_Faq, pid, faq_id) -> Res_Faq:
_e, row = await DB_SESSION_MNG.execute_lambda(
faqs.DBType(),
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_faq(s, pid, faq_id),
)
@ -86,7 +86,7 @@ class FaqService:
pid = uuid.UUID(place_id)
list_err, rows = await DB_SESSION_MNG.execute_lambda(
faqs.DBType(),
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_faqs(s, pid, publishable_only),
)
@ -123,13 +123,13 @@ class FaqService:
if sort_order is None:
# 순서를 안 주면 맨 뒤에 붙인다 — 기존 FAQ 사이에 끼어들어 순서를 흔들지 않게.
_e, rows = await DB_SESSION_MNG.execute_lambda(
faqs.DBType(),
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_faqs(s, pid, False),
)
sort_order = max((r.sort_order for r in rows), default=-1) + 1
row = faqs(
row = place_faqs(
place_id=pid,
question=question,
answer=answer,
@ -137,7 +137,7 @@ class FaqService:
status=FactStatus.VERIFIED.value,
sort_order=sort_order,
)
run_err = await DB_SESSION_MNG.execute_lambda_run([faqs.DBType()], [lambda s: self.crud.add_faq(s, row)])
run_err = await DB_SESSION_MNG.execute_lambda_run([place_faqs.DBType()], [lambda s: self.crud.add_faq(s, row)])
if run_err != ErrorType.SUCCESS:
res.result.SetResult(run_err)
return res
@ -162,7 +162,7 @@ class FaqService:
pid = uuid.UUID(place_id)
fid = uuid.UUID(faq_id)
get_err, faq = await DB_SESSION_MNG.execute_lambda(
faqs.DBType(),
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_faq(s, pid, fid),
)
@ -192,7 +192,7 @@ class FaqService:
now = GTime.UTC()
run_err = await DB_SESSION_MNG.execute_lambda_run(
[faqs.DBType()],
[place_faqs.DBType()],
[lambda s: self._transition_ok(s, fid, current, target, data)],
)
if run_err != ErrorType.SUCCESS:

View File

@ -0,0 +1,118 @@
"""지역 이야기 응답 해석 — 모델이 준 JSON 에서 **쓸 수 있는 항목만** 남긴다.
★ 왜 스키마 검증을 하지 않나
항목 모양의 단일 출처는 `shared/lib/section-data.ts` 다. 그 모양을 파이썬에 한 벌 더 적으면,
프론트가 필드를 하나 늘린 날 서버가 그걸 조용히 떨어뜨린다 — `site_payload._sections` 가
붙여넣기 아이템을 파싱하지 않는 것과 같은 이유다.
그래서 여기서는 **그 항목이 화면에 설 수 있는가**만 본다: 종류마다 하나씩 있는 '이름 칸'.
★ 출처는 두 곳에서 온다
모델이 항목에 단 `source` 가 1순위다. 그게 없으면 Perplexity 가 실제로 읽은
`search_results` 의 첫 줄을 붙인다 — 모델 답변은 환각이 섞이지만 search_results 는
실제로 검색된 주소다(`grounding/channels.py` 와 같은 판단).
둘 다 없으면 항목을 버린다. 출처 없는 사실은 이 레포의 규칙 위반이다.
"""
import json
import re
from common.logger import LOG
# 종류별 '이름 칸' — 이게 비면 화면에 세울 수 없다(제목 없는 카드가 된다).
_TITLE_KEY = {
"songs": "title",
"people": "name",
"chronicle": "title",
"postcard": "line",
"quiz": "question",
}
# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다.
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def _payload_text(payload: dict) -> str:
choices = payload.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return ""
return ((choices[0].get("message") or {}).get("content")) or ""
def _first_source(payload: dict) -> dict | None:
"""Perplexity 가 실제로 읽은 첫 출처. 항목에 source 가 없을 때의 대체값."""
for row in payload.get("search_results") or []:
if isinstance(row, dict) and (row.get("url") or "").startswith("http"):
return {"name": row.get("title") or row.get("url"), "url": row["url"]}
return None
def _clean_source(value) -> dict | None:
"""모델이 준 source. url 이 http 로 시작하지 않으면 없는 것으로 친다 —
"검색결과 참조" 같은 문자열이 그대로 링크가 되면 눌러도 아무 데도 안 간다."""
if not isinstance(value, dict):
return None
url = (value.get("url") or "").strip()
if not url.startswith("http"):
return None
return {"name": (value.get("name") or url).strip(), "url": url}
def parse_items(payload: dict, kind: str, limit: int) -> tuple[list[dict], list[str]]:
"""(쓸 수 있는 항목, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다.
한 항목이 잘못돼도 나머지를 살린다. 지역 하나에 8~14건인데 한 줄 때문에 전부 버리면
그 지역은 다음 재생성까지 빈 채로 남는다.
"""
title_key = _TITLE_KEY.get(kind)
if title_key is None:
raise ValueError(f"모르는 지역 이야기 종류: {kind}")
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
if not text:
return [], ["응답이 비었다"]
try:
envelope = json.loads(text)
except (json.JSONDecodeError, ValueError) as ex:
LOG.w(f"[story] {kind} JSON 파싱 실패: {ex}")
return [], [f"JSON 이 아니다: {ex}"]
if not isinstance(envelope, dict):
return [], ["최상위가 객체가 아니다"]
raw_items = envelope.get("items")
if not isinstance(raw_items, list):
return [], ["items 가 배열이 아니다"]
fallback = _first_source(payload)
out: list[dict] = []
dropped: list[str] = []
for raw in raw_items:
if len(out) >= limit:
break
if not isinstance(raw, dict):
dropped.append("항목이 객체가 아니다")
continue
title = (raw.get(title_key) or "").strip() if isinstance(raw.get(title_key), str) else ""
if not title:
dropped.append(f"{title_key} 가 없다")
continue
item = {k: v for k, v in raw.items() if v not in (None, "", [], {})}
item[title_key] = title
source = _clean_source(raw.get("source")) or fallback
if source is None:
dropped.append(f"{title}: 출처가 없다")
continue
item["source"] = source
# ★ 모델이 "확인" 이라고 우겨도, 대체 출처로 때운 항목은 확인필요다 —
# 그 URL 은 이 항목이 아니라 이번 검색 전체의 출처다.
if item.get("verified") not in ("확인", "확인필요"):
item["verified"] = "확인필요"
elif _clean_source(raw.get("source")) is None:
item["verified"] = "확인필요"
out.append(item)
return out, dropped

View File

@ -0,0 +1,147 @@
"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈. DB·HTTP 없음.
빌드(payload) 시점에 그 지역의 발행된 지역정보(관광지·맛집·축제)와 업체 좌표로
일정을 **즉석 계산**한다. 저장하지 않는 이유: 재료(local_contents)가 갱신되면
다음 빌드에서 일정도 저절로 최신이 된다 — 따로 저장하면 그 동기화를 또 만들어야 한다.
(docs/superpowers/specs/2026-09-03-local-tourapi-sync-design.md)
★ 지어내지 않는 규칙은 여기도 적용된다.
재료가 부족하면 채울 수 있는 만큼만 담고, 하루도 못 채우면 일정 자체를 내지 않는다.
좌표 없는 항목은 거리를 잴 수 없으므로 후보에서 뺀다 — 동선을 보장 못 하는 추천은 틀린 추천이다.
하루의 뼈대: 관광지 2 + 맛집 2(점심·저녁). 진행 중 축제가 있으면 그날 관광지 한 자리를 대신한다.
일자 배분은 업체에서 가까운 순으로 후보를 끊고, 일자 안에서는 최근접 이웃 순서로 동선을 만든다.
"""
from typing import Optional
from common.utils.geo import haversine_km
# 하루 구성 정원. 관광지 자리는 축제가 하나 대신할 수 있다.
_SPOTS_PER_DAY = 2
_MEALS_PER_DAY = 2
# 후보 반경(km). 업체에서 이보다 먼 곳은 '근처'가 아니다 — 1박2일 생활권을 넘는다.
_MAX_RADIUS_KM = 30.0
_STOP_ATTRACTION = "attraction"
_STOP_RESTAURANT = "restaurant"
_STOP_FESTIVAL = "festival"
def _as_float(value) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return None
def _candidates(rows: list[dict], stop_type: str,
base_lat: float, base_lng: float) -> list[dict]:
"""payload 지역정보 행 → 거리 오름차순 후보. 좌표가 없거나 반경 밖이면 뺀다.
행 모양은 스냅샷 local.contents 항목이다. 좌표는 **항목 최상단**의 latitude/longitude 다 —
2026-09-09 에 body 에서 컬럼으로 옮겼다(body 는 렌더러가 읽는 것만 담는다).
"""
out = []
for row in rows:
body = row.get("body") or {}
name = str(body.get("name") or row.get("title") or "").strip()
lat, lng = _as_float(row.get("latitude")), _as_float(row.get("longitude"))
if not name or lat is None or lng is None:
continue
dist = haversine_km(base_lat, base_lng, lat, lng)
if dist > _MAX_RADIUS_KM:
continue
out.append({"type": stop_type, "name": name, "lat": lat, "lng": lng,
"distanceKm": round(dist, 1)})
out.sort(key=lambda c: c["distanceKm"])
return out
def _order_by_route(stops: list[dict], base_lat: float, base_lng: float) -> list[dict]:
"""일자 안 동선: 업체에서 출발해 최근접 이웃 순으로 잇는다."""
remaining = list(stops)
ordered: list[dict] = []
lat, lng = base_lat, base_lng
while remaining:
nxt = min(remaining, key=lambda s: haversine_km(lat, lng, s["lat"], s["lng"]))
remaining.remove(nxt)
ordered.append(nxt)
lat, lng = nxt["lat"], nxt["lng"]
return ordered
def _take(pool: list[dict], count: int) -> list[dict]:
taken, pool[:] = pool[:count], pool[count:]
return taken
# 일자 이름. 화면이 탭 라벨로 그대로 쓴다.
_DAY_LABEL = {1: "첫째 날", 2: "둘째 날", 3: "셋째 날"}
# 며칠짜리인가 → 사람이 읽는 말. 화면은 이 값으로 일정을 가른다(ItineraryItem.duration).
_DURATION = {2: "1박 2일", 3: "2박 3일"}
def _to_stop(cand: dict) -> dict:
"""후보 → 렌더러의 PlannerStop.
★ 머무는 시간·이동 시간은 **비운다.** 우리가 재지 않은 값이라, 넣으면 화면의 시각표가
지어낸 숫자 위에 세워진다. 화면은 없으면 자기 기본값으로 계산한다.
★ URL 을 만들지 않는다 — 지도 검색어(searchQuery)만 준다(LocalPlace 와 같은 규약).
"""
return {
"name": cand["name"],
"searchQuery": cand["name"],
"latitude": cand["lat"],
"longitude": cand["lng"],
}
def _plan_days(days: int, attractions: list[dict], restaurants: list[dict],
festivals: list[dict], base_lat: float, base_lng: float) -> Optional[dict]:
"""일자별 계획. 첫날 하루도 못 채우면 None — 반쪽짜리 일정은 내지 않는다."""
spots = list(attractions)
meals = list(restaurants)
fests = list(festivals)
plan = []
for day in range(1, days + 1):
day_stops = []
# 축제는 하루 하나까지, 관광지 한 자리를 대신한다.
fest = _take(fests, 1)
day_stops += fest
day_stops += _take(spots, _SPOTS_PER_DAY - len(fest))
day_stops += _take(meals, _MEALS_PER_DAY)
if not day_stops:
break
plan.append({
"label": _DAY_LABEL.get(day, f"{day}일차"),
"stops": [_to_stop(s) for s in _order_by_route(day_stops, base_lat, base_lng)],
})
if not plan:
return None
# ★ 렌더러 계약(`shared` ItineraryItem)의 모양으로 낸다. 예전에는 {days, plan[{day, stops}]}
# 라는 우리끼리의 모양이었고, 화면(ItinerarySection)은 그걸 못 읽어 **일정이 통째로
# 안 나왔다** — 수집·계산은 다 됐는데 화면만 비어 있었다(실측 2026-09-09, 조이모텔).
return {"name": _DURATION[days], "duration": _DURATION[days], "days": plan}
def build_itineraries(base_lat: Optional[float], base_lng: Optional[float],
attractions: list[dict], restaurants: list[dict],
festivals: list[dict]) -> list[dict]:
"""업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정. 좌표가 없으면 빈 배열.
입력 행 모양은 스냅샷 local.contents 항목({title, latitude, longitude, body:{name, …}})이다.
"""
if base_lat is None or base_lng is None:
return []
spot_pool = _candidates(attractions, _STOP_ATTRACTION, base_lat, base_lng)
meal_pool = _candidates(restaurants, _STOP_RESTAURANT, base_lat, base_lng)
fest_pool = _candidates(festivals, _STOP_FESTIVAL, base_lat, base_lng)
out = []
for days in (2, 3):
# 세트마다 독립된 풀 복사 — 1박2일이 소비한 후보가 2박3일에서 빠지면 안 된다.
built = _plan_days(days, list(spot_pool), list(meal_pool), list(fest_pool),
base_lat, base_lng)
if built:
out.append(built)
return out

View File

@ -1,32 +1,151 @@
from datetime import date, datetime, timedelta, timezone
import re
import uuid
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import local_contents
from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource
from common.database.model.models import area_contents, place_area_refs, place_channels, places, site_sections
from common.enums import (
AREA_KIND, DBWRType, ErrorType, FactStatus, LocalContentStatus, LocalContentType,
LocalSource, PlaceCategory, SourceType,
)
from common.logger import LOG
from crud.local_content_crud import LocalContentCRUD
from router.v1.local.protocol import ResLocalContentList, ResSyncFestivals, ResWeather, WeatherData
from crud.place_content_crud import PlaceContentCRUD
from crud.place_crud import PlaceCRUD
from crud.site_section_crud import SiteSectionCRUD
from services.external.kakao import KakaoLocalClient, KakaoNotConfigured, KakaoRequestFailed
from services.external.naver import region_key
from services.llm import perplexity
from services import story_service
from router.v1.local.protocol import (
ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, WeatherData,
)
from services.external import tour_api
from services.external.open_meteo import OpenMeteoRequestFailed, fetch_current_weather
from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed, search_festivals
from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed
from services.place_category import guess_food_class
# collect_service.discover_tour_api 가 등록하는 업장 자신의 TourAPI 링크. (contentTypeId, contentId)
_TOUR_LINK = re.compile(r"^tour://(\d+)/(\d+)$", re.I)
# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백. 카페는 카페(FD05)를 뺀다.
# 음식점은 어떤 음식인지 모르면 아무것도 빼지 않는다(한식당에서 양식집을 빼면 안 된다).
_DEFAULT_FOOD_CLASS = {PlaceCategory.CAFE.value: "FD05"}
# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대. 행사 날짜는 한국 날짜다.
_KST = timezone(timedelta(hours=9))
# 업장 반경(m). 2026-09-04 실측(군산 절골길 18)으로 정했다 — specs/2026-09-04-tourapi-radius-spike.md
# 관광지·축제·여행코스 10km: 5km 는 관광지 16건, 10km 는 38건. 원도심 밖 명소가 10km 에서 잡힌다.
# 맛집 5km: 10km 에서도 66건 중 59건이 5km 안이다. 밥은 동네에서 먹는다.
# 종류마다 반경이 다르다(2026-09-08) — 걸어갈 맛집과 차로 갈 관광지를 같은 반경으로 재지 않는다.
# 축제는 반경이 없다 — 업장이 속한 시도 전체를 그대로 싣는다(tour_api.fetch_festivals_in_sido).
# 공용 실체(area_contents.body)에 넣지 않는 키. 컬럼이나 사이트 쪽에 이미 자리가 있는 것들이다.
_BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude")
RESTAURANT_RADIUS_M = 5_000
ATTRACTION_RADIUS_M = 10_000
def _festival_display_end(body: dict) -> datetime | None:
"""eventenddate(YYYYMMDD) → 그 날 KST 자정(다음날 00:00) UTC.
★ 이 값이 있어야 끝난 축제가 발행본에서 저절로 빠진다 —
스냅샷의 노출창 필터(snapshot._local_contents)가 display_end_at 을 본다."""
raw = str(body.get("eventenddate") or "").strip()
if len(raw) != 8 or not raw.isdigit():
return None
try:
end_day = datetime(int(raw[:4]), int(raw[4:6]), int(raw[6:]), tzinfo=_KST)
except ValueError:
return None
return (end_day + timedelta(days=1)).astimezone(timezone.utc)
def _as_float(value) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
class LocalContentService:
def __init__(self):
self.crud = LocalContentCRUD()
self.place_crud = PlaceContentCRUD()
async def list(self, status=None, region_code=None):
res = ResLocalContentList()
err, rows = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list(s, status, region_code)
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list(s, status, region_code)
)
res.result.SetResult(err)
res.contents = list(rows) if err == ErrorType.SUCCESS else []
return res
async def sync_festivals(self, req):
res = ResSyncFestivals()
# ── 업장 반경 주변정보 ───────────────────────────────────────────────
async def sync_place(self, place) -> ResSyncPlace:
"""업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_area_refs 를 맞춘다.
종류마다 따로 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 2026-09-08).
여행코스(25)는 뺐다 — 반경을 넓혀도 데이터가 거의 없다(전북 전체 3건 실측).
빌드가 매번 부른다(services/build_service.run_build) — 발행본은 정적이라 이때 채운 값이 실린다.
★ 실패해도 기존 행을 지우지 않는다 — 직전 값 유지가 이 캐시의 규약이다(모델 주석).
★ 응답에 없는 행은 소프트 삭제한다 — 반경 밖으로 밀렸거나 TourAPI 가 내린 것이다.
hidden(운영자 숨김)은 재수집이 덮어쓰지 않는다(crud.upsert). 이 변경으로 기존에 저장된
여행코스 행도 다음 재수집 때 자연스레 소프트 삭제된다(더는 keep 목록에 없으므로).
★ 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정). 틀린 항목은 운영자가 숨긴다.
"""
res = ResSyncPlace()
place_id = getattr(place, "place_id", None)
if place_id is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None))
if lat is None or lng is None:
# ★ 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색). 주변 정보는 TourAPI 에 이 업소가
# 등록돼 있느냐와 무관하다 — 필요한 건 좌표뿐이다. 찾으면 places 에 박제해 다음부터는 안 부른다.
found = await self._geocode_and_store(place)
if found is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "사업장 좌표가 없어 주변 정보를 받을 수 없습니다(주소로도 찾지 못함)."
return res
lat, lng = found
try:
start = datetime.strptime(req.start_date, "%Y%m%d").date() if req.start_date else date.today()
items = await search_festivals(start_date=start, area_code=req.area_code)
async with tour_api.make_client() as client:
# 종류마다 반경이 달라 따로 부른다 — 맛집은 걸어갈 거리, 축제는 차로 갈 거리다.
restaurants = await tour_api.fetch_nearby(
client, lat, lng, radius_m=RESTAURANT_RADIUS_M, content_type_id="39")
# ★ 업종별 제외(2026-09-08 결정): 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 —
# 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다.
# 숙박 업장은 숙박(32)을 빼야 하는데 애초에 요청하지 않으므로 여기서 할 일이 없다.
own_class = await self._own_food_class(client, place)
if own_class:
before = len(restaurants)
restaurants = [r for r in restaurants if r.get("lclsSystm2") != own_class]
LOG.i(f"[local] place={place_id} 같은 업태({own_class}) 맛집 {before - len(restaurants)}건 제외")
attractions = await tour_api.fetch_nearby(
client, lat, lng, radius_m=ATTRACTION_RADIUS_M, content_type_id="12")
# ★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다(2026-09-08 교체) —
# 위치 색인을 못 믿는다(실측: 반경 20km 를 넓혀도 몇 년 전에 끝난 전시만 잡히고,
# 500m 옆 진행 예정 축제는 안 잡혔다). 시도 코드가 있어야 부를 수 있다.
sido_code = str(getattr(place, "region_code", None) or "")[:2] or None
if not sido_code:
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
derived = region_key(address)
sido_code = derived[:2] if derived else None
today = datetime.now(_KST).date()
if sido_code:
festivals = await tour_api.fetch_festivals_in_sido(
client, lat, lng, sido_code=sido_code, today=today)
else:
LOG.w(f"[local] place={place_id} 시도 코드를 못 구해 축제는 건너뜀")
festivals = []
except TourApiNotConfigured:
res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED)
res.msg = "TOUR_API_KEY를 먼저 설정해주세요."
@ -36,39 +155,308 @@ class LocalContentService:
res.msg = str(ex)
return res
collected = skipped = 0
for item in items:
external_id = str(item.get("contentid") or "")
if not external_id:
skipped += 1
continue
_, existing = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value,
lambda s, eid=external_id: self.crud.get_by_external_id(s, req.region_code, eid),
kept: list[dict] = restaurants + attractions + festivals
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id),
)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
existing = {(int(r.content_type), r.external_id): r for r in (rows or [])} # 조인 결과(area_contents + 거리)
now = datetime.now(timezone.utc)
# 공용 콘텐츠에 실어 둘 지역. 유일성의 근거는 external_id 이고 이건 조회 편의다 —
# 없으면 NULL 로 둔다(지어내지 않는다).
region_code = str(getattr(place, "region_code", None) or "").strip() or None
changed = 0
kept_ids: set = set()
personal: dict = {} # area_contents.local_content_id → 이 사이트만의 값(거리·숨김)
for body in kept:
key = (body["content_type"], body["contentid"])
prev = existing.get(key)
# ★ 실체는 전국 공용이다 — 다른 업장이 이미 넣어 뒀으면 그 행을 그대로 쓴다.
# (source, external_id) 로 upsert 하고 돌려받은 id 로 관계만 잇는다.
# ★ 공용 실체에는 **렌더러가 읽는 것만** 담는다(2026-09-09). 거리는 사이트마다 다르고,
# 좌표·외부 id 는 컬럼이 이미 그 자리다 — body 에 또 두면 한쪽만 갱신되는 날이 온다.
# `lclsSystm2` 만 예외로 남긴다: 렌더러는 안 쓰지만 주변 맛집에서 같은 업태를 뺄 때 쓴다.
shared_body = {k: v for k, v in body.items() if k not in _BODY_DROP}
content_values = {
"source": LocalSource.TOUR_API.value,
"external_id": body["contentid"],
"content_type": body["content_type"],
"kind": AREA_KIND.get(body["content_type"]),
"title": body["name"],
"body": shared_body,
"latitude": _as_float(body.get("latitude")),
"longitude": _as_float(body.get("longitude")),
"region_code": region_code,
# ★ has_image 컬럼은 두지 않는다 — body.firstimage 가 이미 그 사실이다.
# 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다.
"status": LocalContentStatus.PUBLISHED.value,
"display_end_at": (
_festival_display_end(body) if body["content_type"] == LocalContentType.FESTIVAL.value else None
),
"collected_at": now,
}
write_err = await DB_SESSION_MNG.execute_lambda_run(
[area_contents.DBType()],
[lambda s, v=content_values: self.place_crud.upsert_content(s, v)],
)
if existing:
skipped += 1
if write_err != ErrorType.SUCCESS:
continue
row = local_contents(
region_code=req.region_code,
content_type=LocalContentType.FESTIVAL.value,
source=LocalSource.TOUR_API.value,
external_id=external_id,
title=item.get("title"),
body=item,
status=LocalContentStatus.REVIEW.value,
collected_at=datetime.now(timezone.utc),
err_i, rows_i = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s, e=body["contentid"]: self.place_crud.find_content_id(s, LocalSource.TOUR_API.value, e),
)
err = await DB_SESSION_MNG.execute_lambda_run([row.DBType()], [lambda s, r=row: self.crud.insert(s, r)])
collected += int(err == ErrorType.SUCCESS)
skipped += int(err != ErrorType.SUCCESS)
res.collected, res.skipped = collected, skipped
if err_i != ErrorType.SUCCESS or not rows_i:
continue
content_id = rows_i[0] # 단일 컬럼 SELECT 는 scalars() 로 펴져 값이 곧 행이다
kept_ids.add(content_id)
# 원문이 그대로면 관계만 확인하고 넘어간다 — collected_at 만 밀리면 '갱신된 척'이 된다.
await DB_SESSION_MNG.execute_lambda_run(
[place_area_refs.DBType()],
[lambda s, cid=content_id, d=body["distance_m"]: self.place_crud.upsert_ref(s, place_id, cid, d)],
)
# 사이트 개인화(거리·숨김)는 아래에서 한 번에 쓴다 — 항목마다 UPDATE 하면
# 같은 행을 N 번 쓰게 된다(섹션당 한 행이다).
personal[str(content_id)] = {
"kind": AREA_KIND.get(body["content_type"]),
"distanceMeters": body["distance_m"],
"hidden": bool(getattr(prev, "hidden", False)),
}
if prev is None or prev.body != shared_body:
changed += 1
# 이번 응답에 없는 **관계**만 끊는다. 실체는 남긴다 — 다른 업장이 가리키고 있을 수 있다.
_, removed = await DB_SESSION_MNG.execute_lambda_claim(
place_area_refs.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, kept_ids)
)
# ★ 개인화는 사이트 쪽에 쓴다. 이번 응답에 없는 항목은 자연히 빠진다 — 맵을 통째로 갈아
# 끼우기 때문이다. 숨김은 위에서 옛 값을 물려받았으므로 재수집이 되살리지 않는다.
await self._write_site_places(place_id, personal)
counts = {k: 0 for k in (LocalContentType.FESTIVAL.value, LocalContentType.ATTRACTION.value,
LocalContentType.RESTAURANT.value, LocalContentType.COURSE.value)}
for b in kept:
counts[b["content_type"]] += 1
res.festivals = counts[LocalContentType.FESTIVAL.value]
res.attractions = counts[LocalContentType.ATTRACTION.value]
res.restaurants = counts[LocalContentType.RESTAURANT.value]
res.courses = counts[LocalContentType.COURSE.value]
res.changed = changed > 0 or bool(removed)
LOG.i(f"[local] place={place_id} 주변정보 {len(kept)}건(갱신 {changed} · 제거 {removed or 0})")
return res
async def _own_food_class(self, client, place) -> str | None:
"""음식점·카페 업장 자신의 TourAPI 중분류(FD01~FD05). 숙박·병원은 None(제외할 게 없다).
우선순위 — 정확한 쪽부터:
1. 업장이 TourAPI 에 등록돼 있으면(place_channels 의 tour:// 링크) 그 콘텐츠의 lclsSystm2.
주변 항목과 **같은 체계**라 오차가 없다. 링크는 수집(COLLECT)이 상호+좌표 검증을 거쳐 붙인다.
2. 검증 때 박제한 외부 분류 문자열(places.external_category)을 키워드로 매핑.
3. 그래도 모르면 업종 기본값 — 카페는 FD05. 음식점은 None(무엇을 빼야 할지 모른다).
실패는 전부 '제외 없음'으로 떨어진다 — 경쟁 업소가 섞이는 것이 맛집 섹션이 통째로 비는 것보다 낫다.
"""
category = getattr(place, "category", None)
if category not in (PlaceCategory.CAFE.value, PlaceCategory.RESTAURANT.value):
return None
err, links = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(), DBWRType.DB_READ.value,
lambda s: PlaceCRUD().list_links(s, place.place_id, confirmed_only=True),
)
for link in (links or []) if err == ErrorType.SUCCESS else []:
m = _TOUR_LINK.match(str(getattr(link, "url", "") or ""))
if not m:
continue
try:
code = await tour_api.fetch_content_class(client, m.group(2))
except TourApiRequestFailed as ex:
LOG.w(f"[local] 업장 TourAPI 분류 조회 실패(외부 분류로 폴백): {ex}")
code = None
if code:
return code
break
guessed = guess_food_class(getattr(place, "external_category", None))
if guessed:
return guessed
return _DEFAULT_FOOD_CLASS.get(category)
async def _geocode_and_store(self, place) -> tuple[float, float] | None:
"""주소 → 좌표(카카오). 찾으면 places.latitude/longitude 에 박제한다. 키가 없거나 실패하면 None."""
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
if not address:
return None
client = KakaoLocalClient()
if not client.enabled:
LOG.w("[local] 좌표 없는 사업장인데 KAKAO_REST_API_KEY 가 없어 주소로 찾지 못한다")
return None
try:
found = await client.geocode_address(address)
except (KakaoNotConfigured, KakaoRequestFailed) as ex:
LOG.w(f"[local] 주소 → 좌표 실패(계속): {ex}")
return None
finally:
await client.aclose()
if found is None:
return None
lat, lng = found
company_id = getattr(place, "company_id", None)
if company_id is not None:
data = {"latitude": Decimal(str(lat)), "longitude": Decimal(str(lng))}
err, _ = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: PlaceCRUD().update_place(s, company_id, place.place_id, data),
)
if err != ErrorType.SUCCESS:
LOG.w(f"[local] 좌표 박제 실패(이번 수집엔 그대로 씀): {err.name}")
else:
place.latitude, place.longitude = data["latitude"], data["longitude"]
LOG.i(f"[local] place={place.place_id} 주소로 좌표 확보 ({lat:.5f}, {lng:.5f}) — {address[:40]}")
return lat, lng
async def _load_place(self, place_id):
"""회사 스코프 없이 사업장 1건. ★ 공개 조회(guide)와 운영자 화면이 쓴다 — 사장님 API 는 place_service 를 탄다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s, select(places).where(places.place_id == place_id, places.deleted == False).limit(1) # noqa: E712
),
)
return (rows[0] if rows else None) if err == ErrorType.SUCCESS else None
async def sync_place_by_id(self, place_id: uuid.UUID) -> ResSyncPlace:
place = await self._load_place(place_id)
if place is None:
res = ResSyncPlace()
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
return res
return await self.sync_place(place)
async def list_place_contents(self, place_id: uuid.UUID) -> ResPlaceContentList:
res = ResPlaceContentList()
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id, include_hidden=True),
)
res.result.SetResult(err)
res.contents = list(rows) if err == ErrorType.SUCCESS else []
return res
async def set_hidden(self, place_content_id: uuid.UUID, hidden: bool) -> ResPlaceContentList:
res = ResPlaceContentList()
err, count = await DB_SESSION_MNG.execute_lambda_claim(
place_area_refs.DBType(), lambda s: self.place_crud.set_hidden(s, place_content_id, hidden)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
async def get_guide(self, place_id: uuid.UUID) -> ResLocalGuide:
"""에디터 캔버스용 주변 가이드(맛집·명소·축제·코스).
★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 —
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다.
그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다.
★ 일정(itineraries)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다.
"""
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
from services.site_payload import _local
from services.snapshot import _local_contents
res = ResLocalGuide()
place = await self._load_place(place_id)
if place is None:
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
return res
# ★ 아직 한 번도 수집하지 않은 사업장은 **지금** 채운다(cache-aside — 날씨와 같은 규약).
# 빌드 때만 채우면 방금 만든 사업장은 첫 빌드 전까지 캔버스가 계속 "준비 중"이다(2026-09-07 실측).
# 행이 하나라도 있으면 부르지 않는다 — 갱신은 빌드·운영자 재수집이 맡는다.
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id),
)
if err == ErrorType.SUCCESS and not rows:
synced = await self.sync_place(place)
if not synced.result.success:
LOG.w(f"[local] place={place_id} 첫 조회 수집 실패(빈 채로 응답): {synced.msg}")
# ★ 지역 이야기(가요·인물·연표·엽서·퀴즈)도 같은 규약으로 채운다 — 다만 **잡으로** 돌린다.
# 위 TourAPI 는 수 초면 끝나지만 이건 검색을 동반한 LLM 호출 다섯이라 분 단위다.
# 에디터를 여는 요청을 그만큼 붙잡아 두면 사장님에게는 화면이 멈춘 것으로 보인다.
# 이번 응답에는 안 실리고, 다음에 열 때(또는 발행 빌드 때) 들어온다.
await self._ensure_region_stories(place)
snapshot_local = await _local_contents(place)
local, synced_at = _local(snapshot_local, _as_float(place.latitude), _as_float(place.longitude))
res.attractions = local.get("attractions") or []
res.restaurants = local.get("restaurants") or []
res.festivals = local.get("festivals") or []
res.courses = local.get("courses") or []
res.synced_at = synced_at
return res
async def _write_site_places(self, place_id, places_map: dict) -> None:
"""주변 항목의 **사이트별 값**(거리·숨김)을 `site_sections` 한 행에 쓴다.
★ 배열이 아니라 ref → 값 **맵**이다. 화면에 순서대로 서는 항목(songs·people…)이 아니라
"공용 항목 하나에 이 사이트가 덧붙인 값" 조회표라, 읽을 때마다 훑을 이유가 없다.
정렬 기준(가까운 순·사진 있는 것 먼저)은 읽는 쪽이 갖는다.
★ 사이트가 없으면 만들지 않고 건너뛴다 — 사이트를 세우는 건 발행 쪽 결정이다
(`build_service.ensure_site`). 다음 수집이 사이트가 생긴 뒤 다시 쓴다.
"""
from services.build_service import ensure_site
try:
site = await ensure_site(str(place_id))
except Exception as ex: # noqa: BLE001 — 발행 전 업장은 사이트가 없을 수 있다
LOG.i(f"[local] place={place_id} 사이트가 없어 개인화 저장을 건너뛴다: {ex}")
return
if site is None:
return
values = {
"site_id": site.site_id,
"section_id": "local",
"data": {"kind": "local", "places": places_map},
"source_type": SourceType.API.value,
"status": FactStatus.VERIFIED.value,
"sort_order": 0,
}
err = await DB_SESSION_MNG.execute_lambda_run(
[site_sections.DBType()], [lambda s: SiteSectionCRUD().upsert(s, values)],
)
if err != ErrorType.SUCCESS:
LOG.w(f"[local] place={place_id} 사이트 개인화 저장 실패: {err.name}")
async def _ensure_region_stories(self, place) -> None:
"""지역 데이터가 비어 있으면 잡을 하나 넣는다 — **보험 경로**다.
★ 정규 경로는 위저드다: 수집이 끝날 때(collect_service)와 생성 단계(place_service)가
같은 잡을 걸고, 사장님은 **에디터에 들어가기 전에** 다 채워진 화면을 본다.
여기는 그 경로를 안 거친 업장(옛 데이터·수집을 건너뛴 경우)을 위한 자리다.
★ 잡으로 돌린다. 이 함수는 에디터가 화면을 그리려고 부른 요청 안에 있어서,
여기서 1분을 붙잡으면 사장님에게는 화면이 멈춘 것으로 보인다.
"""
if not perplexity.is_configured():
return
region_code = str(getattr(place, "region_code", None) or "").strip()
if region_code and await story_service.has_stories(region_code):
return
await story_service.enqueue_region_job(place)
# ── 지역 캐시(area_contents) — 운영자 수기 항목·날씨 ────────────────
async def publish(self, ids, user_id):
res = ResLocalContentList()
err, _ = await DB_SESSION_MNG.execute_lambda_claim(
local_contents.DBType(), lambda s: self.crud.publish(s, ids, user_id)
area_contents.DBType(), lambda s: self.crud.publish(s, ids, user_id)
)
res.result.SetResult(err)
return res
@ -82,7 +470,7 @@ class LocalContentService:
res.msg = "노출 종료는 시작보다 뒤여야 합니다."
return res
err, count = await DB_SESSION_MNG.execute_lambda_claim(
local_contents.DBType(), lambda s: self.crud.update(s, content_id, data)
area_contents.DBType(), lambda s: self.crud.update(s, content_id, data)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
@ -90,7 +478,7 @@ class LocalContentService:
async def end(self, content_id):
res = ResLocalContentList()
err, count = await DB_SESSION_MNG.execute_lambda_claim(
local_contents.DBType(), lambda s: self.crud.end(s, content_id)
area_contents.DBType(), lambda s: self.crud.end(s, content_id)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
@ -99,7 +487,7 @@ class LocalContentService:
"""Cache-aside weather lookup; stale data survives upstream failures."""
res = ResWeather()
err, cached = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value,
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.get_weather(s, region_code),
)
if err != ErrorType.SUCCESS:
@ -139,10 +527,11 @@ class LocalContentService:
"updated_at": now,
}
write_err = await DB_SESSION_MNG.execute_lambda_run(
[local_contents.DBType()], [lambda s: self.crud.upsert_weather(s, values)]
[area_contents.DBType()], [lambda s: self.crud.upsert_weather(s, values)]
)
if write_err != ErrorType.SUCCESS:
res.result.SetResult(write_err)
return res
res.weather = WeatherData.model_validate(weather)
return res

View File

@ -3,7 +3,7 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import media, places
from common.database.model.models import place_photos, places
from common.enums import DBWRType, ErrorType, MediaStatus, SourceType
from common.models.gmodel import UserInfo
from crud.media_crud import IMediaCRUD, MediaCRUD
@ -66,7 +66,7 @@ class MediaService:
# publishable_only 의 승인 조건만 CRUD 에 넘기고, alt 조건은 목록 계산과 함께 아래에서 건다.
status = MediaStatus.APPROVED.value if publishable_only else None
list_err, rows = await DB_SESSION_MNG.execute_lambda(
media.DBType(),
place_photos.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_media(
s, uuid.UUID(place_id), status, False, unit_id, publishable_only

View File

@ -68,3 +68,39 @@ def _match(text: str) -> Optional[PlaceCategory]:
if any(word in text for word in words):
return category
return None
# ── 음식 중분류(TourAPI lclsSystm2) 추정 ─────────────────────────────────
# 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준이다(2026-09-08 결정).
# TourAPI 분류체계(lclsSystmCode2 실측) — FD01 한식 · FD02 외국식(중·일·서양·기타외국·퓨전)
# · FD03 간이음식(제과·피자/햄버거/샌드위치·치킨·김밥분식·이동음식) · FD04 주점 · FD05 카페/찻집.
#
# ★ 순서가 결과를 바꾼다. 카카오는 카페를 "음식점 > 카페 > …" 아래 두므로 "음식점"이 항상 붙어 있다 —
# 그래서 "음식점" 은 판정어로 쓰지 않고, 구체적인 업태(카페·주점·간이·외국식)를 한식보다 먼저 본다.
# ★ 한식은 마지막이고 판정어가 좁다("한식"·"한정식"·"백반"·"국밥"…). 고기·회 같은 재료명은 넣지 않는다 —
# "양식 > 스테이크" 를 고기라고 한식으로 넣으면 서양식 스테이크집이 한식이 된다.
_FOOD_CLASS_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("FD05", ("카페", "커피", "찻집", "디저트", "음료", "주스", "빙수", "케이크", "브런치")),
("FD04", ("주점", "술집", "호프", "맥주", "이자카야", "포차", "와인바", "칵테일", "펍")),
("FD03", ("제과", "베이커리", "빵", "도넛", "피자", "햄버거", "샌드위치", "치킨", "분식", "김밥",
"떡볶이", "간식", "토스트", "패스트푸드")),
("FD02", ("중식", "중국", "일식", "일본", "초밥", "라멘", "양식", "서양", "이탈리", "파스타", "스테이크",
"프렌치", "프랑스", "멕시", "아시아", "베트남", "태국", "인도", "퓨전")),
("FD01", ("한식", "한정식", "백반", "국밥", "찌개", "국수", "냉면", "삼겹", "갈비", "곱창", "족발", "보쌈",
"해장국", "설렁탕", "감자탕", "칼국수", "횟집", "회")),
]
def guess_food_class(category_name: Optional[str]) -> Optional[str]:
"""외부 분류 문자열 → TourAPI 음식 중분류 코드(FD01~FD05). 모르면 None(제외 없음).
예 — 카카오 "음식점 > 카페 > 커피전문점" → FD05 · 네이버 "카페,디저트" → FD05 ·
카카오 "음식점 > 한식 > 육류,고기" → FD01 · "음식점 > 양식 > 스테이크,립" → FD02
"""
text = (category_name or "").replace(" ", "")
if not text:
return None
for code, words in _FOOD_CLASS_KEYWORDS:
if any(word in text for word in words):
return code
return None

View File

@ -4,7 +4,7 @@ from fastapi import Depends
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_links, places, units
from common.database.model.models import place_channels, places, place_units
from common.enums import DBWRType, ErrorType, JobStatus, JobType, LinkChannel, PlaceCategory, PlaceStatus, SourceType
from common.logger import LOG
from common.models.gmodel import PageParams, UserInfo
@ -243,6 +243,8 @@ class PlaceService:
phone=base.get("phone") or base.get("virtualPhone") or None,
latitude=Decimal(str(coord.get("y"))) if coord.get("y") else None,
longitude=Decimal(str(coord.get("x"))) if coord.get("x") else None,
# 네이버 상세의 분류("펜션"·"카페,디저트"). 주변 맛집 경쟁업소 제외의 폴백 근거(실측 2026-09-08: 있음).
category_name=str(base.get("category") or "").strip() or None,
)
verified = await self.verify_place(user_info, place_id, verify_req)
if not verified.result.success:
@ -268,7 +270,7 @@ class PlaceService:
title=f"{official} 네이버 플레이스", discovered_by=SourceType.OWNER),
)
await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda s: self.crud.confirm_link_by_url(
s, uuid.UUID(place_id), canonical, uuid.UUID(user_info.user_id), GTime.UTC()
),
@ -321,6 +323,9 @@ class PlaceService:
"verified_at": now,
"verified_by": uuid.UUID(user_info.user_id),
}
# ★ 값이 왔을 때만 덮는다 — 네이버 URL 재검증이 분류를 못 읽었다고 카카오가 준 값을 지우면 안 된다.
if (req.category_name or "").strip():
data["external_category"] = req.category_name.strip()[:200]
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.update_place(s, uid, uuid.UUID(place_id), data),
@ -336,7 +341,7 @@ class PlaceService:
# 실측상 Perplexity 는 이 채널을 잘 못 찾는다 — 검증 단계에서 건지는 게 확실하다.
# 등록만 하고 확정하지는 않는다(확정은 수집 잡이 어댑터 유무를 보고 판단).
if (req.place_url or "").strip():
link = place_links(
link = place_channels(
place_id=uuid.UUID(place_id),
channel=LinkChannel.OFFICIAL_SITE.value,
url=req.place_url.strip(),
@ -345,7 +350,7 @@ class PlaceService:
discovered_at=now,
)
await DB_SESSION_MNG.execute_lambda_run(
[place_links.DBType()],
[place_channels.DBType()],
[lambda s: self.crud.add_link(s, link)],
)
return await self.get_place(user_info, place_id)
@ -358,7 +363,7 @@ class PlaceService:
res.result.SetResult(err_type)
return res
list_err, rows = await DB_SESSION_MNG.execute_lambda(
units.DBType(),
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_units(s, uuid.UUID(place_id)),
)
@ -378,9 +383,9 @@ class PlaceService:
res.result.SetResult(err_type)
return res
unit = units(place_id=uuid.UUID(place_id), name=req.name.strip(), sort_order=req.sort_order)
unit = place_units(place_id=uuid.UUID(place_id), name=req.name.strip(), sort_order=req.sort_order)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[units.DBType()],
[place_units.DBType()],
[lambda s: self.crud.add_unit(s, unit)],
)
if run_err != ErrorType.SUCCESS:
@ -397,7 +402,7 @@ class PlaceService:
res.result.SetResult(err_type)
return res
list_err, rows = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_links(s, uuid.UUID(place_id), confirmed_only),
)
@ -421,7 +426,7 @@ class PlaceService:
res.result.SetResult(err_type)
return res
link = place_links(
link = place_channels(
place_id=uuid.UUID(place_id),
channel=req.channel.value,
url=req.url.strip(),
@ -430,7 +435,7 @@ class PlaceService:
discovered_at=GTime.UTC(),
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[place_links.DBType()],
[place_channels.DBType()],
[lambda s: self.crud.add_link(s, link)],
)
if run_err != ErrorType.SUCCESS:
@ -451,7 +456,7 @@ class PlaceService:
return res
run_err, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda s: self.crud.confirm_link(
s, uuid.UUID(place_id), uuid.UUID(link_id), uuid.UUID(user_info.user_id), GTime.UTC()
),
@ -492,7 +497,7 @@ class PlaceService:
# 채널 URL 발견(Perplexity)은 **잡의 첫 단계**다 — 링크가 하나도 없어도 수집을 시작할 수 있다.
# 여기서는 이미 확정된 링크 수만 세어 응답에 실어준다(진행 상황 표시용).
link_err, links = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_links(s, uuid.UUID(place_id), True),
)
@ -554,7 +559,7 @@ class PlaceService:
force 로 재분석할 때 이 엔드포인트를 쓴다."""
from sqlalchemy import func, select
from common.database.model.models import media
from common.database.model.models import place_photos
from services.external import gemini
res = Res_StartVision()
@ -567,18 +572,18 @@ class PlaceService:
res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED)
return res
conds = [media.place_id == uuid.UUID(place_id), media.deleted == False] # noqa: E712
conds = [place_photos.place_id == uuid.UUID(place_id), place_photos.deleted == False] # noqa: E712
if not req.force:
# ★ 미분석 기준은 alt_text 다 — label 은 수집 어댑터가 페이지 캡션으로 채운다.
# label 로 세면 캡션 있는 사진이 전부 '분석됨'으로 빠져 pending 0 이 된다
# (crud/media_crud.list_media 의 같은 주석 참고).
from sqlalchemy import func as sa_func
from sqlalchemy import or_ as sa_or
conds.append(sa_or(media.alt_text.is_(None), sa_func.btrim(media.alt_text) == ""))
conds.append(sa_or(place_photos.alt_text.is_(None), sa_func.btrim(place_photos.alt_text) == ""))
cnt_err, rows = await DB_SESSION_MNG.execute_lambda(
media.DBType(),
place_photos.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, select(func.count()).select_from(media).where(*conds)),
lambda s: DB_SESSION_MNG.execute(s, select(func.count()).select_from(place_photos).where(*conds)),
)
res.pending_media = int(rows[0] or 0) if cnt_err == ErrorType.SUCCESS and rows else 0
if res.pending_media == 0:
@ -817,7 +822,7 @@ class PlaceService:
★ 근거로 쓸 확인된 fact 가 없으면 잡을 만들지 않는다 —
근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다."""
from common.database.model.models import facts as facts_model
from common.database.model.models import place_facts as facts_model
from crud.fact_crud import FactCRUD
from services.external import gemini_text

View File

@ -0,0 +1,41 @@
{
"_generated": "npm run export:prompts — 손으로 고치지 않는다",
"rules": "\n[공통 규칙]\n1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.\n2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.\n3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.\n4. 근거가 확실하면 verified 를 \"확인\", 애매하면 \"확인필요\" 로 적는다. 애매한 걸 \"확인\" 으로 올리지 않는다.\n5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.\n6. 설명 문장은 항목당 두 문장을 넘기지 않는다.\n7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.\n",
"specs": {
"songs": {
"kind": "songs",
"label": "가요 다방",
"maxItems": 8,
"task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n"
},
"people": {
"kind": "people",
"label": "인물 열전",
"maxItems": 10,
"task": "[해야 할 일]\n[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다.\n문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다.\n\n[스키마]\n{ \"kind\":\"people\", \"version\":1, \"title\":\"인물 열전\", \"items\":[\n { \"name\":\"이름\", \"aka\":\"호·예명\", \"years\":\"1902–1950\", \"role\":\"소설가\",\n \"oneLine\":\"한 문장 소개\", \"imageQuery\":\"사진 검색어\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· \"~ 출신으로 알려진\" 처럼 근거가 전언뿐이면 verified 를 \"확인필요\" 로 한다.\n· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.\n· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.\n"
},
"chronicle": {
"kind": "chronicle",
"label": "시간의 골목",
"maxItems": 14,
"task": "[해야 할 일]\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\n\n[스키마]\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n"
},
"postcard": {
"kind": "postcard",
"label": "오늘의 엽서",
"maxItems": 12,
"task": "[해야 할 일]\n[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.\n사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.\n\n[스키마]\n{ \"kind\":\"postcard\", \"version\":1, \"title\":\"오늘의 엽서\", \"items\":[\n { \"line\":\"한 문장\", \"hashtags\":[\"#태그\"], \"place\":\"장소\",\n \"postmark\":\"소인에 찍을 짧은 지명\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.\n· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.\n· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.\n"
},
"quiz": {
"kind": "quiz",
"label": "뒤집어 보는 질문",
"maxItems": 12,
"task": "[해야 할 일]\n[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다.\n질문은 검색하면 바로 나오는 단답형이 아니라 \"왜\" 와 \"어떻게\" 를 묻는 것으로 한다.\n\n[스키마]\n{ \"kind\":\"quiz\", \"version\":1, \"title\":\"뒤집어 보는 질문\", \"items\":[\n { \"question\":\"질문 한 문장\", \"hint\":\"두 문장 이내 힌트\",\n \"topic\":\"관련 장소·주제\", \"level\":\"초등|중등|어른\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.\n· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.\n· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.\n"
}
}
}

View File

@ -0,0 +1,61 @@
"""지역 이야기 프롬프트 계약 — 문장은 여기서 만들지 않고 **읽어 온다**.
★ 단일 출처는 `solution/shared/src/lib/section-prompts.ts` 다.
사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와 서버가 도는 프롬프트가 같아야 한다 —
갈리면 "빌더에서 뽑은 것과 자동으로 채워진 것의 모양이 다르다"가 조용히 생긴다.
이 파일이 읽는 `section_prompts.json` 은 `npm run export:prompts` 산출물이고 커밋된다.
**손으로 고치지 않는다.**
"""
import json
from functools import lru_cache
from pathlib import Path
_SPEC_PATH = Path(__file__).with_name("section_prompts.json")
SYSTEM_PROMPT = (
"너는 지역 콘텐츠 리서처다. 검색으로 확인한 것만 쓰고, 확인하지 못한 값은 필드를 통째로 뺀다. "
"JSON 하나만 출력한다."
)
@lru_cache(maxsize=1)
def _spec() -> dict:
"""산출물 로드. 없으면 즉시 터뜨린다 — 프롬프트 없이 도는 생성 잡은 빈 값을 쓴다."""
try:
return json.loads(_SPEC_PATH.read_text(encoding="utf-8"))
except FileNotFoundError as ex: # pragma: no cover - 배포 누락은 기동 시 바로 드러난다
raise RuntimeError(
f"{_SPEC_PATH.name} 이 없다 — 레포 루트에서 `npm run export:prompts` 를 돌려야 한다"
) from ex
def kinds() -> list[str]:
return list(_spec()["specs"].keys())
def label(kind: str) -> str:
return _spec()["specs"][kind]["label"]
def max_items(kind: str) -> int:
return int(_spec()["specs"][kind]["maxItems"])
def build_prompt(kind: str, region: str) -> str:
"""지역 하나 × 종류 하나의 프롬프트.
★ 업소 이름을 넣지 않는다. 이 값은 **지역**에 붙어 같은 지역 사이트가 나눠 쓴다 —
업소 하나를 골라 넣으면 그 집 이야기가 옆집 사이트에 실린다.
`shared/section-prompts.ts` 의 `buildSectionPrompt` 가 같은 분기를 갖고 있다.
"""
spec = _spec()["specs"].get(kind)
if spec is None:
raise ValueError(f"모르는 지역 이야기 종류: {kind}")
head = (
"너는 지역 콘텐츠 리서처다. 아래 조건에 맞는 JSON 하나만 출력한다.\n"
f"\n[지역] {region}\n"
f"\n아래 '해야 할 일'에서 [지역] = {region}. "
"업소가 지정되지 않았으므로 특정 업소를 가리키는 문장(connection 등)은 쓰지 않는다.\n"
)
return f"{head}\n{spec['task']}\n{_spec()['rules']}{spec['rules']}"

View File

@ -31,6 +31,7 @@ from common.enums import (
SourceType,
)
from common.logger import LOG
from services.itinerary import build_itineraries
# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다.
SCHEMA_VERSION = 1
@ -65,6 +66,26 @@ _CHANNEL_TITLE = {
LinkChannel.ETC.value: "기타 채널",
}
# 저장된 look 이 없을 때 쓰는 기본 생김새 — 에디터의 '심플' 템플릿
# (`solution/frontend/src/data/industryData.ts` 의 LOOK.simple)과 같은 값이다.
# ★ 왜 필요한가 (실측 2026-09-08, `/s/stay-mumum-gunsan`)
# 이 키가 없으면 `<head>` 에 --tpl-font-heading·--tpl-radius·--tpl-texture 가 아예
# 안 실리고, 발행본은 렌더러 CSS 의 폴백으로 떨어진다. 그 폴백의 제목 서체는
# `--font-serif`(명조)다 — 그래서 위저드를 안 돈 사업장의 발행본만 제목이 명조로,
# 모서리는 렌더러 기본값으로 나가 에디터 미리보기(고딕)와 눈에 띄게 갈렸다.
# 색은 업종 기본이 있는데 생김새만 없어서 생긴 구멍이라, 고르지 않았을 때의 모습도 정해 둔다.
_DEFAULT_LOOK = {
"fontHeading": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
"fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
"radius": "0.75rem",
"borderWidth": "1px",
"shadow": "0 1px 2px rgb(0 0 0 / 0.06)",
"headingTracking": "-0.02em",
"headingWeight": "700",
"sectionSpace": "4rem",
}
# 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다.
# ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다:
# templateId ← sites.template_id (POST /v1/place/{id}/site/template)
@ -84,11 +105,22 @@ _DEFAULT_THEME = {
"fontStyle": "Modern Editorial",
"colors": {"primary": "#18181b", "secondary": "#52525b", "bg": "#ffffff",
"card": "#fafafa", "text": "#09090b", "accent": "#2563eb"},
# ★ 순서·구성이 시안(/s/stay)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은
# 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다.
# ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사·
# 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection).
# ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
# (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라
# 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다.
"sections": [
("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False),
("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "예약 안내", False),
("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False),
("local", "지역 정보", False), ("faq", "자주 묻는 질문", False),
("event", "소식", False),
("info", "기본 정보", True), ("booking", "예약 안내", False),
("video", "영상", False),
("photos", "사진 갤러리", False), ("map", "오시는 길", True),
("festival", "계절별 축제", False), ("local", "지역 정보", False),
("itinerary", "추천 일정", False), ("story", "지역 이야기", False),
("faq", "자주 묻는 질문", False), ("weather", "날씨", False),
],
},
PlaceCategory.CAFE.value: {
@ -313,9 +345,15 @@ def _theme(site, theme_spec: dict) -> dict:
# ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 —
# 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다.
# 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다.
# 저장된 look 이 있으면 그게 이긴다. 다만 **덮어쓰기가 아니라 덧칠이다** — 프론트가
# 일부 키만 보낸 옛 저장값에 빈칸이 생기면 그 칸만 명조·렌더러 기본값으로 떨어진다.
look = saved.get("look")
if isinstance(look, dict) and look:
out["look"] = {k: v for k, v in look.items() if isinstance(v, str) and v.strip()}
cleaned = (
{k: v for k, v in look.items() if isinstance(v, str) and v.strip()}
if isinstance(look, dict)
else {}
)
out["look"] = {**_DEFAULT_LOOK, **cleaned}
return out
@ -444,7 +482,7 @@ def _yyyymmdd(value) -> str:
def _festival(row: dict):
"""FestivalEntry. 이름이 없으면 버린다 — 이름 없는 행사는 화면에 걸 수 없다."""
body = row.get("body") or {}
name = _text(row.get("title")) or _text(body.get("title"))
name = _text(body.get("name")) or _text(row.get("title"))
if not name:
return None
@ -468,7 +506,7 @@ def _festival(row: dict):
}
if period:
entry["period"] = period
location = _text(body.get("addr1"))
location = _text(body.get("location"))
if location:
entry["location"] = location
description = _text(body.get("overview"))
@ -477,32 +515,80 @@ def _festival(row: dict):
# 공식 홈페이지는 출처가 준 값일 때만 싣는다. 형식이 URL 이 아니면 링크로 걸지 않는다.
if homepage.startswith("http://") or homepage.startswith("https://"):
entry["officialUrl"] = homepage
# 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와
# 같은 모양으로 그리려면 필요하다(2026-09-07, 도보 시간 필터 형식 결정).
_put_distance(entry, body)
image = _text(body.get("imageUrl"))
if image:
entry["imageUrl"] = image
return entry
def _local_place(row: dict, category: str):
"""LocalPlace(주변 명소·맛집).
"""LocalPlace.
★ title 컬럼만 믿는다. 이 두 종류(ATTRACTION·RESTAURANT)를 채우는 수집기가 아직 없어서
body 의 모양이 정해지지 않았다 — 있지도 않은 키를 가정해 파싱하면 수집기가 붙는 날
조용히 빈 값이 나간다. 지금은 테이블 스키마가 보장하는 것(title)만 쓰고,
설명·거리는 실제 수집기가 붙을 때 그 모양을 보고 채운다."""
name = _text(row.get("title"))
★ 2026-09-09 부터 `body` 가 **이미 렌더러 모양**이다(`name`·`location`·`imageUrl`) —
수집 시점에 바꿔 넣는다(`external/tour_api._normalize`). 예전에는 TourAPI 원문 이름을
저장하고 빌드마다 여기서 바꿔 실었다. 같은 변환을 발행할 때마다 다시 하는 셈이었고,
캔버스와 발행본이 각자 바꾸면 갈릴 자리였다.
그래서 여기가 하는 일은 둘뿐이다 — 업종 라벨을 붙이고, 사이트별 거리를 표기로 바꾼다.
"""
body = row.get("body") or {}
name = _text(body.get("name")) or _text(row.get("title"))
if not name:
return None
return {"name": name, "category": category, "searchQuery": name}
entry = {"name": name, "category": category, "searchQuery": _text(body.get("searchQuery")) or name}
for key in ("location", "imageUrl"):
value = _text(body.get(key))
if value:
entry[key] = value
_put_distance(entry, body)
return entry
def _local(snapshot_local: dict) -> tuple[dict, str | None]:
def _put_distance(entry: dict, body: dict) -> None:
"""distanceMeters → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다."""
# ★ 원값은 사이트 개인화(site_sections.data.places[].distanceMeters)에서 온다 —
# 공용 실체에는 거리가 없다(업장마다 다르다). 스냅샷이 그 값을 body 에 얹어 준다.
meters = body.get("distanceMeters")
distance = _distance_text(meters)
if not distance:
return
entry["distanceText"] = distance
entry["distanceMeters"] = int(meters)
def _distance_text(meters) -> str:
"""850 → "850m", 1234 → "1.2km". 없으면 빈 문자열."""
try:
m = int(meters)
except (TypeError, ValueError):
return ""
if m < 0:
return ""
if m < 1000:
return f"{m}m"
# 반올림은 '5 는 올림'으로 — f"{1.45:.1f}" 는 부동소수 탓에 1.4 가 나온다.
return f"{(m + 50) // 100 / 10:.1f}km"
def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) -> tuple[dict, str | None]:
"""스냅샷의 지역 정보 → LocalContents.
★ 스냅샷이 이미 걸렀다(PUBLISHED + 노출 기간 안). 여기서 더 거르지 않고 모양만 바꾼다 —
fact·사진과 같은 분업이다.
★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도
payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다."""
payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다.
★ itineraries(1박2일·2박3일 일정)는 저장하지 않고 여기서 즉석 계산한다 —
재료가 갱신되면 다음 빌드에서 일정도 저절로 최신이 된다(services/itinerary.py).
렌더러에 아직 이 필드의 자리가 없다 — 모르는 필드는 무시되므로 화면은 변하지 않는다."""
contents = (snapshot_local or {}).get("contents") or []
local = {"attractions": [], "restaurants": [], "festivals": []}
# courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07).
local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []}
synced_at = None
# 일정 계산용 원본 행(좌표가 body 에 있다). payload 항목은 좌표를 싣지 않으므로 따로 모은다.
raw = {"attractions": [], "restaurants": [], "festivals": []}
for row in contents:
if not isinstance(row, dict):
@ -523,16 +609,38 @@ def _local(snapshot_local: dict) -> tuple[dict, str | None]:
entry = _festival(row)
if entry:
local["festivals"].append(entry)
raw["festivals"].append(row)
elif content_type == LocalContentType.ATTRACTION.value:
entry = _local_place(row, "관광지")
if entry:
local["attractions"].append(entry)
raw["attractions"].append(row)
elif content_type == LocalContentType.RESTAURANT.value:
entry = _local_place(row, "맛집")
if entry:
local["restaurants"].append(entry)
raw["restaurants"].append(row)
elif content_type == LocalContentType.COURSE.value:
entry = _local_place(row, "여행코스")
if entry:
local["courses"].append(entry)
elif content_type == LocalContentType.STORY.value:
# ★ 지역 이야기는 **모양을 바꾸지 않는다.** body 가 이미 렌더러 계약
# (`shared/lib/section-data.ts` 의 SongItem·PeopleItem…) 그대로다.
# 여기서 키를 손대면 사장님이 손으로 붙여넣은 같은 종류의 JSON 과 모양이 갈린다 —
# 화면은 둘을 한 배열로 이어 그린다.
kind = _text(row.get("kind"))
items = (row.get("body") or {}).get("items")
if kind and isinstance(items, list) and items:
local.setdefault("story", {})[kind] = [i for i in items if isinstance(i, dict)]
# 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다.
itineraries = build_itineraries(
base_lat, base_lng, raw["attractions"], raw["restaurants"], raw["festivals"]
)
if itineraries:
local["itineraries"] = itineraries
return local, synced_at
@ -708,7 +816,10 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
# ★ 스냅샷에서 읽는다 — 여기서 DB 를 다시 읽으면 '스냅샷과 다른 페이지'가 나온다(파일 상단 원칙).
# 지역 정보를 스냅샷에 담는 필터링은 services/snapshot._local_contents 가 한다.
# 옛 스냅샷에는 "local" 키가 없다. 그때는 빈 채로 나가고, 다음 빌드에서 채워진다.
local, local_synced_at = _local(snapshot.get("local") or {})
local, local_synced_at = _local(
snapshot.get("local") or {},
_as_float(snap_place.get("latitude")), _as_float(snap_place.get("longitude")),
)
if local_synced_at:
local["syncedAt"] = local_synced_at

View File

@ -4,7 +4,7 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import ai_check_results, places, publish_logs, site_versions, sites
from common.database.model.models import places, site_publish_logs, site_versions, sites
from common.enums import (
BuildStatus,
DBWRType,
@ -140,22 +140,47 @@ class SiteService:
if v_err != ErrorType.SUCCESS:
version = None
_ai_err, ai_rows = await DB_SESSION_MNG.execute_lambda(
ai_check_results.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s, select(ai_check_results).where(
ai_check_results.place_id == uuid.UUID(place_id),
ai_check_results.deleted == False, # noqa: E712
).order_by(ai_check_results.checked_at.desc()).limit(20),
raise_error=False,
),
)
# ★ AI 노출 점검 이력은 읽지 않는다. `place_ai_checks` 는 한 번도 쓰지 않아
# 마이그레이션 0006 이 뗐다(JobType.AI_CHECK 도 아직 미배선이다).
# 여기서 그 표를 계속 부르면 SEO 진단이 통째로 죽는다 — 지금 그랬다.
# reports 모듈이 붙는 날 표와 함께 되살린다.
report = evaluate(
await build_snapshot(place), verified=place.verified_at is not None,
site=site, version=version, ai_checks=list(ai_rows or []),
site=site, version=version, ai_checks=[],
)
return Res_SeoAudit(**report)
async def preview_payload(self, user_info: UserInfo, place_id: str) -> dict | None:
"""에디터 미리보기가 쓰는 **발행본과 똑같은 payload**. DB 도 파일도 건드리지 않는다.
★ 왜 필요한가 (2026-09-09)
미리보기와 발행본이 **렌더러를 두 벌** 쓰고 있었다 — 캔버스는
`frontend/features/builder/canvas/variants/*`, 발행본은 `site/src/sections/*`.
둘이 공유하는 건 타입과 CSS 토큰뿐이라 같은 데이터로도 다른 그림이 나왔다.
실측(2026-09-09): 캔버스는 소개 섹션을 **설명 문구를 자리표시로** 그리는데
발행본은 데이터가 0자면 섹션째 뺀다 — 사장님은 채워진 화면을 보고 발행해
절반이 사라진 페이지를 받는다. `shared/lib/section-data.ts` 가 경고해 둔
"빌더에서는 보이는데 발행하면 없다"가 파서가 아니라 **렌더러**에서 났다.
★ 그래서 미리보기도 이 payload 하나만 먹는다. 발행이 굽는 것과 같은 함수
(`snapshot.build_snapshot` → `site_payload.to_site_payload`)를 그대로 거치므로,
여기서 갈릴 자리가 없다. 버전은 아직 없으니 0 으로 넘긴다 — 화면에 안 쓰인다.
"""
from services.build_service import ensure_site, _load_links
from services.site_payload import to_site_payload
from services.snapshot import build_snapshot
err_type, place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS or place is None:
return None
site = await ensure_site(place_id)
links = await _load_links(place_id)
snapshot = await build_snapshot(place)
# ★ version 은 None 이다. 발행 전이라 버전 행이 없고, payload 의 site.version 은
# 캐시 무효화 키라 미리보기에서는 뜻이 없다(to_site_payload 가 0 으로 떨어뜨린다).
return to_site_payload(place, snapshot, site, None, links)
# ---- 사이트 주소(네임스페이스) ---------------------------------------
# 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다.
@ -585,7 +610,7 @@ class SiteService:
res.result.SetResult(ErrorType.SITE_NOT_FOUND)
return res
l_err, rows = await DB_SESSION_MNG.execute_lambda(
publish_logs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_logs(s, site.site_id, 50)
site_publish_logs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_logs(s, site.site_id, 50)
)
if l_err == ErrorType.SUCCESS:
res.logs = [PublishLogData.model_validate(r) for r in rows]
@ -623,12 +648,12 @@ class SiteService:
await DB_SESSION_MNG.execute_lambda_claim(
sites.DBType(), lambda s: self.crud.update_site(s, site.site_id, {"status": target.value})
)
log = publish_logs(
log = site_publish_logs(
site_id=site.site_id,
site_version_id=site.current_version_id,
action=req.action.value,
result=PublishResult.SUCCESS.value,
actor_user_id=uuid.UUID(user_info.user_id),
)
await DB_SESSION_MNG.execute_lambda_run([publish_logs.DBType()], [lambda s: self.crud.add_log(s, log)])
await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: self.crud.add_log(s, log)])
return await self.get_site(user_info, place_id)

View File

@ -22,13 +22,17 @@ from sqlalchemy import or_, select
from common.category_schema import get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts, faqs, local_contents, media, units
from common.database.model.models import (
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_units,
site_sections, sites,
)
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
ErrorType,
FactStatus,
LocalContentStatus,
LocalSource,
MediaStatus,
PlaceCategory,
)
@ -38,11 +42,14 @@ from services.external.naver import region_key
_PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES)
# 지역 정보를 종류별로 몇 건까지 박제할지.
# ★ 스냅샷은 site_versions.snapshot 에 통째로 들어간다. 지역 캐시는 region_code 단위 공용이라
# 한 지역에 수백 건이 쌓일 수 있고, 그걸 다 박제하면 버전 행마다 그만큼이 복사된다.
# 화면(LocalGuideSection)도 그만큼 보여주지 않는다 — 최근 수집분 위주로 자른다.
# ★ 종류별(맛집·관광지·축제·코스) 노출 상한 — 화면·캔버스·발행본 모두 이 수까지만 보여준다(2026-09-07 결정).
# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다.
# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다.
_LOCAL_MAX_PER_TYPE = 20
# ★ 지역 이야기는 종류당 **한 행**이다(항목은 body.items 안에 있다 — migrations/0004
# `uq_local_contents_kind`). 그래서 다섯 종류가 위 상한 안에서 나란히 선다.
# 지역 원문(body)에서 스냅샷으로 옮기지 않는 키.
# ★ TourAPI 원본을 통째로 담은 필드라 정규화된 값과 100% 중복이고, 축제 1건의 크기를 두 배로 만든다.
# site_payload 는 정규화된 키만 읽는다.
@ -56,30 +63,30 @@ async def build_snapshot(place) -> dict:
schema = get_schema(category)
fact_rows = await _select(
select(facts).where(
facts.place_id == pid,
facts.deleted == False, # noqa: E712
facts.status.in_(_PUBLISHABLE),
select(place_facts).where(
place_facts.place_id == pid,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_PUBLISHABLE),
)
)
unit_rows = await _select(
select(units).where(units.place_id == pid, units.deleted == False) # noqa: E712
.order_by(units.sort_order.asc())
select(place_units).where(place_units.place_id == pid, place_units.deleted == False) # noqa: E712
.order_by(place_units.sort_order.asc())
)
faq_rows = await _select(
select(faqs).where(
faqs.place_id == pid,
faqs.deleted == False, # noqa: E712
faqs.status.in_(_PUBLISHABLE),
).order_by(faqs.sort_order.asc())
select(place_faqs).where(
place_faqs.place_id == pid,
place_faqs.deleted == False, # noqa: E712
place_faqs.status.in_(_PUBLISHABLE),
).order_by(place_faqs.sort_order.asc())
)
# ★ 승인된 사진만. Vision 신뢰도가 낮아 확인 큐에 남은 사진은 사이트에 안 나간다.
media_rows = await _select(
select(media).where(
media.place_id == pid,
media.deleted == False, # noqa: E712
media.status == MediaStatus.APPROVED.value,
).order_by(media.sort_order.asc())
select(place_photos).where(
place_photos.place_id == pid,
place_photos.deleted == False, # noqa: E712
place_photos.status == MediaStatus.APPROVED.value,
).order_by(place_photos.sort_order.asc())
)
local_rows = await _local_contents(place)
@ -164,10 +171,14 @@ async def build_snapshot(place) -> dict:
async def _local_contents(place) -> dict:
"""사업장 지역의 노출 가능한 지역 정보. {"region_code", "contents":[...]}
"""사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]}
두 캐시를 합친다 —
area_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목
place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김·종료된 것 제외
★ 노출 가능 = PUBLISHED + 노출 기간 안.
local_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
area_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
REVIEW 는 아직 사람이 확인하지 않은 외부 API 원문이고, ENDED 는 내린 것이다.
둘 중 하나라도 사이트로 새면 '미검증 값 노출 금지'가 깨진다 — fact 를 VERIFIED/CORRECTED 로,
사진을 APPROVED 로 거르는 것과 같은 규칙을 같은 이유로 적용한다.
@ -192,48 +203,154 @@ async def _local_contents(place) -> dict:
region_code = region_key(
str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
) or ""
if not region_code:
return {"region_code": None, "contents": []}
now = datetime.now(timezone.utc)
query = (
select(local_contents)
.where(
local_contents.region_code == region_code,
local_contents.deleted == False, # noqa: E712
local_contents.status == LocalContentStatus.PUBLISHED.value,
or_(local_contents.display_start_at.is_(None), local_contents.display_start_at <= now),
or_(local_contents.display_end_at.is_(None), local_contents.display_end_at > now),
contents: list[dict] = []
seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다
# ── 지역 캐시(area_contents): 날씨 + 운영자가 수기로 발행한 항목 ──
if region_code:
query = (
select(area_contents)
.where(
area_contents.region_code == region_code,
area_contents.deleted == False, # noqa: E712
# ★ **지역 단위 항목만** 본다 — 날씨와 지역 이야기다(external_id 없이 지역에 한 벌).
# 관광지·맛집·축제는 같은 표에 있지만 업장마다 거리가 달라, 아래 사이트 쪽에서
# 개인화 값과 함께 읽는다. 여기서 같이 긁으면 거리 없는 항목이 먼저 들어와
# 종류별 상한을 채워 버린다(실측 2026-09-09: 주변 12건이 전부 거리 없이 나갔다).
area_contents.external_id.is_(None),
area_contents.status == LocalContentStatus.PUBLISHED.value,
or_(area_contents.display_start_at.is_(None), area_contents.display_start_at <= now),
or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now),
)
.order_by(area_contents.content_type.asc(), area_contents.collected_at.desc())
)
.order_by(local_contents.content_type.asc(), local_contents.collected_at.desc())
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query)
)
if err != ErrorType.SUCCESS:
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
rows = []
contents += _local_rows(rows or [], seen)
# ── 업장 주변: 공용 실체(area_contents) × 사이트 개인화(site_sections) ──
# ★ 2026-09-09 에 자리를 갈랐다. 공용 실체는 지역이 나눠 쓰고(거리를 담을 수 없다),
# 거리·숨김은 사이트마다 다르다. 그래서 관계 테이블이 아니라 **사이트 섹션**에서 읽는다.
# 정렬은 여기가 한다 — 사진 있는 것 먼저, 그다음 가까운 순(2026-09-07 결정).
# 저장 쪽에 정렬을 구워 두면 기준이 바뀔 때 전 사이트를 다시 써야 한다.
place_id = getattr(place, "place_id", None)
if place_id is not None:
personal = await _site_places(place_id)
if personal:
ids = [uuid.UUID(k) for k in personal if _is_uuid(k)]
shared_q = select(area_contents).where(
area_contents.local_content_id.in_(ids),
area_contents.deleted == False, # noqa: E712
or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now),
)
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, shared_q),
)
if err != ErrorType.SUCCESS:
LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}")
rows = []
merged = []
for row in rows or []:
mine = personal.get(str(row.local_content_id)) or {}
if mine.get("hidden"):
continue
body = dict(row.body if isinstance(row.body, dict) else {})
# 거리만 얹는다. 공용 실체는 이미 렌더러 모양이라 여기서 이름을 바꾸지 않는다.
if mine.get("distanceMeters") is not None:
body["distanceMeters"] = mine["distanceMeters"]
merged.append((row, body, mine.get("distanceMeters")))
merged.sort(key=lambda t: (not bool(t[1].get("imageUrl")), t[2] if t[2] is not None else 1 << 30))
contents += _local_rows(
[_Row(r, b) for r, b, _ in merged], seen, source=LocalSource.TOUR_API.value
)
return {"region_code": region_code or None, "contents": contents}
class _Row:
"""area_contents 행 + 사이트 값이 얹힌 body. `_local_rows` 가 두 캐시를 같은 모양으로 읽게 한다."""
__slots__ = ("content_type", "source", "title", "body", "collected_at", "kind",
"latitude", "longitude")
def __init__(self, row, body):
self.content_type, self.source = row.content_type, row.source
self.title, self.body, self.collected_at, self.kind = row.title, body, row.collected_at, row.kind
self.latitude, self.longitude = row.latitude, row.longitude
def _is_uuid(value: str) -> bool:
try:
uuid.UUID(value)
except (ValueError, AttributeError, TypeError):
return False
return True
async def _site_places(place_id) -> dict:
"""이 사이트의 주변 개인화 맵(ref → {kind, distanceMeters, hidden}).
★ 사이트가 없으면 빈 맵이다 — 발행 전 업장은 주변 정보가 안 나간다. 그건 옳다.
개인화 값이 없다는 건 "이 사이트에 그 항목이 붙은 적이 없다"는 뜻이다.
"""
q = (
select(site_sections.data)
.join(sites, sites.site_id == site_sections.site_id)
.where(
sites.place_id == place_id,
sites.deleted == False, # noqa: E712
site_sections.section_id == "local",
site_sections.deleted == False, # noqa: E712
)
.limit(1)
)
err, rows = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query)
site_sections.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, q)
)
if err != ErrorType.SUCCESS:
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
# 빈 채로 나가면 렌더러가 그 섹션을 아예 그리지 않는다.
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
return {"region_code": region_code, "contents": []}
if err != ErrorType.SUCCESS or not rows:
return {}
data = rows[0]
return (data or {}).get("places") or {} if isinstance(data, dict) else {}
seen: dict[int, int] = {}
contents = []
for row in rows or []:
def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]:
"""행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다.
seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다."""
out = []
for row in rows:
content_type = int(row.content_type)
# 종류별 상한. 위 order_by 가 collected_at 내림차순이라 최근 수집분이 남는다.
kind = getattr(row, "kind", None)
taken = seen.get(content_type, 0)
if taken >= _LOCAL_MAX_PER_TYPE:
continue
seen[content_type] = taken + 1
body = row.body if isinstance(row.body, dict) else {}
contents.append({
entry = {
"content_type": content_type,
"source": row.source,
"source": source if source is not None else row.source,
"title": row.title,
"body": {k: v for k, v in body.items() if k not in _LOCAL_BODY_DROP},
"collected_at": _iso(row.collected_at),
})
return {"region_code": region_code, "contents": contents}
}
if kind:
entry["kind"] = kind
# ★ 좌표는 **컬럼**에서 온다(2026-09-09). 예전에는 body.mapx/mapy 였는데, 같은 값이
# 컬럼에도 있어 한쪽만 갱신될 자리였다. 일정 조립(services/itinerary)이 이걸 읽는다.
for key, value in (("latitude", getattr(row, "latitude", None)),
("longitude", getattr(row, "longitude", None))):
if value is not None:
entry[key] = str(value)
out.append(entry)
return out
def _iso(value) -> str | None:
@ -250,7 +367,7 @@ def _iso(value) -> str | None:
async def _select(query) -> list:
err, rows = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, query),
)

View File

@ -0,0 +1,237 @@
"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다.
★ 왜 지역 단위인가
이 다섯은 업장의 사실이 아니라 도시의 사실이다. 군산 이야기는 군산 숙소가 같이 쓴다.
키를 place_id 로 잡으면 같은 지역에 숙소 50곳이 들어올 때 같은 곡 목록을 50번 만든다
— `area_contents` 가 region_code 를 키로 두는 것과 같은 이유이고, 여기가 그 표를 쓴다.
★ 왜 종류마다 따로 부르나
다섯을 한 프롬프트에 넣으면 (1) 출력이 길어 잘리고 (2) 한 종이 실패하면 전부 다시 돌고
(3) 검색 출처가 어느 항목 것인지 섞인다. 종류당 1회, 한 번에 그 종류 전부다 —
항목당 1회는 반대로 낭비다(검색이 한 번에 여러 건을 답한다).
★ 왜 Perplexity 한 곳인가
이 값들은 **출처가 붙어야** 쓸 수 있다(항목의 `source.url`). Gemini 는 검색을 안 해서
주소를 지어내고, Perplexity 는 실제로 읽은 `search_results` 를 함께 준다.
구조는 프롬프트의 [스키마] 블록이 잡고, 파이썬은 모양을 다시 적지 않는다
(`grounding/story.py` 머리주석).
★ 검수 게이트를 두지 않는다 (2026-09-09 결정 — docs/DECISIONS.md)
생성분은 PUBLISHED 로 저장한다. 대신 항목마다 `verified`·`source` 가 실려 화면이 그걸 밝히고,
틀린 항목은 사장님이 에디터에서 뺀다. 공공데이터(맛집·관광지)를 검수 없이 싣는 것과 같은 규약이다.
"""
import uuid
import httpx
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents
from common.enums import DBWRType, ErrorType, JobType, LocalContentStatus, LocalContentType, LocalSource
from common.utils.gtime import GTime
from common.logger import LOG
from crud.local_content_crud import LocalContentCRUD
from services.grounding import story as grounding
from services.llm import perplexity
from services.prompts import story as prompts
# ★ 다섯을 **순차로** 부른다. 처음엔 동시에 띄웠는데 실측(2026-09-09, 전북 군산시)에서
# 다섯 중 둘이 HTTP 429 로 떨어졌다 — 같은 키로 나가는 호출이라 한 지역이 자기 자신을 막는다.
# 순차로 돌려도 건당 9~15초라 다섯이 1분 안이고(같은 실측), 이건 잡이라 사람이 기다리지 않는다.
# "빨리 끝내려다 절반을 잃는" 교환이 성립하지 않는다.
# ★ 채널 발견(90s)보다 길게 잡는다. 같은 실측에서 가요 다방이 90초를 넘겼다 —
# "이 도시를 노래한 곡" 은 후보를 넓게 훑어야 해서 검색 왕복이 더 많다.
_TIMEOUT = httpx.Timeout(240.0, connect=10.0)
# 생성분에는 노출 종료가 없다. 축제와 달리 "지난 것"이 되지 않는다 —
# 1966년 곡은 내년에도 1966년 곡이다. 갱신은 운영자가 다시 돌릴 때만 일어난다.
_DISPLAY_END = None
# 봉투 버전. 사장님이 붙여넣는 JSON 의 `version` 과 같은 자리다 — 읽는 쪽이 둘을 구분하지
# 않아야 하므로 값도 같게 둔다(`shared/lib/section-data.ts`).
_ENVELOPE_VERSION = 1
async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str) -> tuple[list[dict], list[str]]:
"""종류 하나. 실패는 예외로 올리지 않고 빈 목록으로 돌려준다 —
한 종류가 죽어도 나머지 넷은 채워야 한다."""
body = {
"model": perplexity.DEFAULT_MODEL,
"messages": [
{"role": "system", "content": prompts.SYSTEM_PROMPT},
{"role": "user", "content": prompts.build_prompt(kind, region_label)},
],
"max_tokens": perplexity.DEFAULT_MAX_TOKENS,
}
try:
payload = await perplexity.call(body, client=client)
except perplexity.PerplexityNotConfigured:
return [], ["PERPLEXITY_API_KEY 미설정"]
except perplexity.PerplexityError as ex:
LOG.w(f"[story] {kind} 호출 실패 region={region_label}: {ex}")
return [], [f"호출 실패: {ex}"]
items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind))
LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림")
return items, dropped
async def generate_region_stories(region_code: str, region_label: str, kinds: list[str] | None = None) -> dict:
"""지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다.
★ 기존 행을 먼저 지우지 않는다. 순번 키로 덮어쓰므로, 새로 받은 것이 적으면 뒤쪽 옛 행이
남는다 — 그건 의도다. 이번 검색이 부실했다고 지난번에 확인된 항목까지 날리지 않는다.
"""
wanted = kinds or prompts.kinds()
crud = LocalContentCRUD()
result: dict[str, int] = {}
notes: list[str] = []
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
for kind in wanted:
items, dropped = await _generate_kind(client, kind, region_label)
result[kind] = len(items)
notes.extend(f"{kind}: {d}" for d in dropped)
if not items:
continue
# ★ 한 지역 × 한 종류 = 한 행이다(`uq_local_contents_kind`, migrations/0004).
# 항목마다 행을 만들면 같은 곡이 두 번 서거나 재생성이 옛 행을 못 덮는다.
# 봉투 모양은 사장님이 붙여넣는 JSON 과 **같다** — 읽는 쪽이 둘을 구분하지 않는다.
label = prompts.label(kind)
values = {
"local_content_id": uuid.uuid4(),
"region_code": region_code,
"content_type": LocalContentType.STORY.value,
"kind": kind,
"source": LocalSource.LLM.value,
"title": label,
"body": {"kind": kind, "version": _ENVELOPE_VERSION, "title": label, "items": items},
"status": LocalContentStatus.PUBLISHED.value,
"published_at": GTime.UTC(),
"display_end_at": _DISPLAY_END,
"collected_at": GTime.UTC(),
}
# ★ execute_lambda_run 이다(claim 아님). claim 은 func 이 (ErrorType, 행수)를 돌려주길
# 기대하는데 upsert 는 ErrorType 만 준다 — sync_place 가 공용 콘텐츠를 넣는 방식과 같다.
err = await DB_SESSION_MNG.execute_lambda_run(
[area_contents.DBType()], [lambda s, v=values: crud.upsert_kind(s, v)],
)
if err != ErrorType.SUCCESS:
LOG.w(f"[story] 저장 실패 region={region_code} {kind}: {err.name}")
notes.append(f"{kind}: 저장 실패 {err.name}")
LOG.i(f"[story] region={region_code}({region_label}) 완료: {result}")
return {"region_code": region_code, "counts": result, "notes": notes}
async def has_stories(region_code: str) -> bool:
"""이 지역에 이미 이야기가 있나. cache-aside 판단용 — 한 건이라도 있으면 다시 부르지 않는다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s: crud_list(s, region_code),
)
return err == ErrorType.SUCCESS and bool(rows)
async def crud_list(session, region_code: str):
return await LocalContentCRUD().list_kinds(session, region_code)
async def run_local_sync(job: dict) -> dict:
"""LOCAL_SYNC 잡 핸들러 — **에디터에 들어가기 전에 지역 데이터를 다 채운다.**
payload: {place_id?, region_code, region_label, kinds?}
★ 왜 둘을 한 잡에 묶나
업장 반경(TourAPI 맛집·관광지·축제)과 지역 이야기(LLM)는 성격이 다르지만, 사장님에게는
"주변 이야기가 채워졌나" 하나다. 잡을 둘로 나누면 위저드가 둘을 따로 기다려야 하고,
하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다.
사진 분석(VISION)을 수집에서 떼어 낸 것과는 사정이 다르다 — 그건 각각 몇 분이라 실패
비용이 컸지만, 이 둘은 합쳐 1분대이고 유료 재호출도 아래 가드가 막는다.
★ 업장 것과 지역 것의 반복 단위가 다르다
반경 수집은 **업장마다** 해야 한다(좌표가 다르다). 이야기는 **지역에 한 번**이면 된다 —
같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다. 그래서 이야기 쪽만 가드가 붙는다.
★ 멱등하다. 이야기는 순번이 아니라 (region_code, kind) 한 행을 덮어쓰고, 반경 수집은
external_id 로 upsert 한다 — lease 만료로 다시 돌아도 행이 늘지 않는다.
"""
payload = job["payload"]
region_code = (payload.get("region_code") or "").strip()
region_label = (payload.get("region_label") or "").strip()
place_id = payload.get("place_id")
if not region_code or not region_label:
raise ValueError("LOCAL_SYNC payload 에 region_code/region_label 이 필요하다")
out: dict = {"region_code": region_code}
# ── 1. 업장 반경(TourAPI) — 맛집·관광지·축제 ──────────────────────
if place_id:
# 순환 import 회피 — local_content_service 가 이 모듈을 부른다(cache-aside 보험 경로).
from services.local_content_service import LocalContentService
synced = await LocalContentService().sync_place_by_id(uuid.UUID(str(place_id)))
out["nearby"] = {
"festivals": synced.festivals, "attractions": synced.attractions,
"restaurants": synced.restaurants, "ok": bool(synced.result.success),
}
if not synced.result.success:
LOG.w(f"[story] place={place_id} 반경 수집 실패(이야기는 계속한다): {synced.msg}")
# ── 2. 지역 이야기(LLM) — 지역에 한 번 ────────────────────────────
if not perplexity.is_configured():
out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"}
return out
if await has_stories(region_code):
# ★ 같은 지역 두 번째 숙소다. 다시 부르면 같은 답에 요금만 두 번 낸다.
out["stories"] = {"skipped": "이미 있다"}
return out
out["stories"] = await generate_region_stories(region_code, region_label, payload.get("kinds"))
return out
def region_label_of(place) -> str:
"""프롬프트에 넣을 지명("전북특별자치도 군산시").
★ region_code("52군산시")를 그대로 넣지 않는다 — 숫자가 붙은 문자열을 지명으로 주면
모델이 그걸 지명의 일부로 읽는다. 주소 앞 두 토큰이 사람이 부르는 이름이다.
★ 주소가 없으면 빈 문자열이다. 지역을 모르면 부르지 않는다 — 어디 이야기인지 모르는
채로 물으면 모델이 아무 도시나 고른다.
"""
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
if not address:
return ""
tokens = address.split()
return " ".join(tokens[:2]) if len(tokens) >= 2 else tokens[0]
async def enqueue_region_job(place) -> str | None:
"""업장의 지역 데이터 잡을 큐에 넣고 job_id 를 돌려준다. 지역을 모르면 넣지 않는다.
★ dedupe 는 **업장 단위**다(`local:{place_id}`). 반경 수집이 업장마다 필요해서다 —
지역 이야기의 중복 호출은 잡 안의 `has_stories` 가드가 막는다.
★ 부르는 곳이 둘이다: 수집 완료 직후(collect_service)와 위저드의 생성 단계(place_service).
먼저 넣은 잡이 아직 살아 있으면 enqueue_job 이 그 id 를 돌려준다 — 위저드는 그걸 기다린다.
"""
from crud.job_crud import JobQueue
from services.job_service import enqueue_job
place_id = getattr(place, "place_id", None)
code = str(getattr(place, "region_code", None) or "").strip()
label = region_label_of(place)
if not place_id or not code or not label:
LOG.w(f"[story] place={place_id} 지역을 특정할 수 없어 지역 데이터 잡을 넣지 않는다")
return None
job_id, created = await enqueue_job(
JobQueue(), JobType.LOCAL_SYNC,
{"place_id": str(place_id), "region_code": code, "region_label": label},
dedupe_key=f"local:{place_id}",
)
if created:
LOG.i(f"[story] place={place_id} region={code}({label}) 지역 데이터 잡 등록 job={job_id}")
return job_id

View File

@ -10,7 +10,7 @@
import uuid
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import media, places
from common.database.model.models import place_photos, places
from common.enums import DBWRType, ErrorType, MediaStatus, PlaceCategory
from common.logger import LOG
from common.utils.gtime import GTime
@ -47,7 +47,7 @@ async def run_vision(job: dict) -> dict:
# force 가 아니면 아직 분석 안 된 사진만 — 같은 사진을 다시 태우면 요금만 나간다.
list_err, rows = await DB_SESSION_MNG.execute_lambda(
media.DBType(),
place_photos.DBType(),
DBWRType.DB_READ.value,
lambda s: _media_crud.list_media(s, uuid.UUID(place_id), unlabeled_only=not force),
)
@ -58,7 +58,7 @@ async def run_vision(job: dict) -> dict:
# 객실·메뉴 이름을 힌트로 준다 — 라벨이 units 와 같은 어휘로 나오면 매칭이 쉬워진다.
unit_err, unit_rows = await DB_SESSION_MNG.execute_lambda(
media.DBType(),
place_photos.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_units(s, uuid.UUID(place_id)),
)
@ -101,7 +101,7 @@ async def run_vision(job: dict) -> dict:
approved = not result.needs_review
status = MediaStatus.APPROVED.value if approved else MediaStatus.PENDING_REVIEW.value
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
media.DBType(),
place_photos.DBType(),
lambda s, r=result, st=status: _media_crud.apply_vision(
s, by_key[r.origin_url].media_id, r.label, r.alt_text, r.confidence, st, now
),

View File

@ -112,7 +112,7 @@ async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client
"""검증: 사장님이 문구를 고쳐 승인(CORRECTED)한 뒤 COPY 잡이 재생성을 돌린다.
기대결과: 고친 문구가 그대로 남는다 — ★ 자동 생성이 사람의 판단을 덮어쓰지 않는다."""
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import faqs
from common.database.model.models import place_faqs
from common.utils.gtime import GTime
from crud.faq_crud import FaqCRUD
@ -129,7 +129,7 @@ async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client
# 재생성이 미확인 FAQ 를 내리는 단계(COPY 잡이 실제로 부르는 그 함수).
await DB_SESSION_MNG.execute_lambda_claim(
faqs.DBType(),
place_faqs.DBType(),
lambda s: FaqCRUD().expire_generated(s, uuid.UUID(pid), GTime.UTC()),
)
after = await _list(client, h, pid, publishable_only=True)

View File

@ -0,0 +1,144 @@
"""지역 이야기 생성 — 응답 해석과 payload 경계.
★ 실호출은 하지 않는다. `APP_ENV=test` 면 .env 를 안 읽어 키가 비고, 이 테스트가 검증하는 건
"모델이 뭐라고 답했을 때 무엇을 남기는가" 다 — 그건 고정 응답으로 전부 재현된다.
"""
import os
os.environ.setdefault("APP_ENV", "test")
from common.enums import LocalContentType # noqa: E402
from services.grounding import story as grounding # noqa: E402
from services.prompts import story as prompts # noqa: E402
from services.site_payload import _local # noqa: E402
def _reply(content: str, search_results=None) -> dict:
return {
"choices": [{"message": {"content": content}}],
"search_results": search_results or [],
}
# ── 프롬프트 ────────────────────────────────────────────────────────────
def test_프롬프트는_shared_산출물에서_온다():
"""다섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다."""
assert set(prompts.kinds()) == {"songs", "people", "chronicle", "postcard", "quiz"}
text = prompts.build_prompt("songs", "전북 군산시")
assert "[지역] 전북 군산시" in text
assert "[지역]을 노래한 대중가요" in text # task 원문
assert "[공통 규칙]" in text
# ★ 빈칸이 남으면 모델이 그걸 지명으로 읽는다.
assert "(주소를" not in text and "(가게" not in text
def test_지역_생성은_업소를_가리키지_않는다():
"""지역 단위 값이라 특정 업소 문장을 못 쓰게 못박는다 — 옆집 사이트에도 실리는 값이다."""
assert "업소가 지정되지 않았으므로" in prompts.build_prompt("songs", "전북 군산시")
# ── 응답 해석 ────────────────────────────────────────────────────────────
def test_출처가_없으면_항목을_버린다():
payload = _reply('{"kind":"songs","items":[{"title":"금강 나그네"}]}')
items, dropped = grounding.parse_items(payload, "songs", 8)
assert items == []
assert any("출처가 없다" in d for d in dropped)
def test_검색결과를_대체_출처로_쓰되_확인필요로_내린다():
"""search_results 는 이번 **검색 전체**의 출처지 그 항목의 근거가 아니다."""
payload = _reply(
'{"kind":"songs","items":[{"title":"금강 나그네","verified":"확인"}]}',
search_results=[{"title": "세계일보", "url": "https://example.com/a"}],
)
items, _ = grounding.parse_items(payload, "songs", 8)
assert len(items) == 1
assert items[0]["source"]["url"] == "https://example.com/a"
assert items[0]["verified"] == "확인필요"
def test_항목_출처가_있으면_확인을_유지한다():
payload = _reply(
'{"kind":"songs","items":[{"title":"금강 나그네","verified":"확인",'
'"source":{"name":"세계일보","url":"https://example.com/song"}}]}'
)
items, _ = grounding.parse_items(payload, "songs", 8)
assert items[0]["verified"] == "확인"
def test_열리지_않는_출처는_없는_것으로_친다():
""""검색결과 참조" 같은 문자열이 링크가 되면 눌러도 아무 데도 안 간다."""
payload = _reply(
'{"kind":"songs","items":[{"title":"금강 나그네","source":{"name":"검색","url":"검색결과 참조"}}]}'
)
items, dropped = grounding.parse_items(payload, "songs", 8)
assert items == []
assert any("출처가 없다" in d for d in dropped)
def test_이름칸이_없는_항목만_버리고_나머지는_살린다():
"""한 줄 때문에 지역 하나가 통째로 비면 다음 재생성까지 빈 채로 남는다."""
payload = _reply(
'{"kind":"people","items":['
'{"name":"채만식","source":{"name":"한국민족문화대백과","url":"https://example.com/1"}},'
'{"role":"소설가","source":{"name":"x","url":"https://example.com/2"}},'
'{"name":"고은","source":{"name":"y","url":"https://example.com/3"}}]}'
)
items, dropped = grounding.parse_items(payload, "people", 10)
assert [i["name"] for i in items] == ["채만식", "고은"]
assert any("name 가 없다" in d for d in dropped)
def test_코드펜스를_둘러도_읽는다():
"""규칙 1 로 금지했지만 모델은 종종 어긴다."""
payload = _reply(
'```json\n{"kind":"quiz","items":[{"question":"왜 군산에 일본식 가옥이 남았을까?",'
'"source":{"name":"군산시","url":"https://example.com/q"}}]}\n```'
)
items, _ = grounding.parse_items(payload, "quiz", 12)
assert len(items) == 1
def test_상한을_넘으면_자른다():
rows = ",".join(
f'{{"line":"문장{i}","source":{{"name":"x","url":"https://example.com/{i}"}}}}' for i in range(20)
)
items, _ = grounding.parse_items(_reply(f'{{"kind":"postcard","items":[{rows}]}}'), "postcard", 12)
assert len(items) == 12
def test_JSON_이_아니면_전부_버리고_이유를_남긴다():
items, dropped = grounding.parse_items(_reply("죄송합니다. 정보를 찾지 못했습니다."), "songs", 8)
assert items == []
assert dropped and "JSON 이 아니다" in dropped[0]
# ── payload 경계 ─────────────────────────────────────────────────────────
def test_스냅샷의_이야기가_payload_로_나간다():
"""★ 항목 모양을 바꾸지 않는다 — 사장님이 붙여넣은 같은 종류의 JSON 과 한 배열로 이어진다."""
snapshot = {
"region_code": "52군산시",
"contents": [
{
"content_type": LocalContentType.STORY.value,
"kind": "songs",
"title": "가요 다방",
"body": {
"kind": "songs",
"version": 1,
"title": "가요 다방",
"items": [{"title": "금강 나그네", "artist": "이미자"}],
},
"collected_at": "2026-09-09T00:00:00+00:00",
}
],
}
local, synced_at = _local(snapshot, None, None)
assert local["story"]["songs"] == [{"title": "금강 나그네", "artist": "이미자"}]
assert synced_at == "2026-09-09T00:00:00+00:00"
def test_이야기가_없으면_story_키_자체가_없다():
"""빈 배열을 만들지 않는다 — 렌더러가 '있는데 비었다'와 '없다'를 구분한다."""
local, _ = _local({"region_code": "52군산시", "contents": []}, None, None)
assert "story" not in local

View File

@ -14,7 +14,7 @@
JobType.VISION ✓ services/vision_service.run_vision — Gemini Vision 사진 분류 + alt
JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거)
JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트
JobType.LOCAL_SYNC → local 모듈이 붙을 때
JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회)
JobType.AI_CHECK → reports 모듈이 붙을 때
"""
@ -79,6 +79,7 @@ def _register_builtin():
from services.collect_service import run_collect
from services.build_service import run_build
from services.copy_service import run_copy
from services.story_service import run_local_sync
from services.vision_service import run_vision
if JobType.COLLECT.value not in HANDLERS:
@ -89,6 +90,8 @@ def _register_builtin():
HANDLERS[JobType.COPY.value] = run_copy
if JobType.BUILD.value not in HANDLERS:
HANDLERS[JobType.BUILD.value] = run_build
if JobType.LOCAL_SYNC.value not in HANDLERS:
HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync
_register_builtin()

View File

@ -3,7 +3,7 @@
# WORKER_CONCURRENCY=3 python worker_main.py
#
# 수집·비전분석·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다. API 는 잡만 적재하고 즉시 응답하며,
# 실제 처리는 이 프로세스가 한다. 큐는 PostgreSQL(job.jobs) — 원자적 claim + lease 소유권이라
# 실제 처리는 이 프로세스가 한다. 큐는 PostgreSQL(jobs) — 원자적 claim + lease 소유권이라
# 워커를 몇 개 띄우든(docker compose --scale) 같은 잡이 두 번 돌지 않는다.
import asyncio

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /admin/

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://web4ai.o2osolution.ai/</loc>
<lastmod>2026-08-12T00:00:00+09:00</lastmod>
<priority>1.0</priority>
</url>
</urlset>

View File

@ -38,6 +38,15 @@ const LOOK = {
headingTracking: '0em',
headingWeight: '400',
sectionSpace: '4rem',
/**
* 갱지 결 — 가로 3px · 세로 4px 간격의 아주 옅은 줄 두 겹.
*
* ★ 이 칸이 비어 있어서 '옛 항구'를 골라도 면이 매끈했다. 타입(`TemplateLook.texture`)에도
* 있고 발행본이 심는 코드(`seo/head.ts`)도 있는데 **주는 쪽만 없었다** — 색과 서체는
* 레트로인데 종이가 아니라, 인쇄물이 아니라 '갈색 웹페이지'로 보였다.
*/
texture:
'repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)',
},
} as const;
@ -92,10 +101,11 @@ function templatesFor(
toneLabel: '레트로 감성',
description: retro.description,
colors: {
// 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2.
primary: '#1b1a15',
secondary: '#4c4739',
bg: '#e4dac0',
card: '#f2ebd9',
card: '#efe7d3',
text: '#1b1a15',
// 레트로의 정체성이 이 주(朱) 잉크다 — 업종 accent 로 갈아끼우지 않는다.
accent: '#bf2f1b',
@ -103,7 +113,16 @@ function templatesFor(
fontStyle: '옛 간판체',
look: LOOK.retro,
// 이 템플릿이 팔려는 게 바로 이 아이템들이다.
defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'],
/**
* 이 템플릿이 데려오는 아이템.
*
* ★ 예전 값('course' · 'schedule')은 **렌더러에 없는 타입**이었다. 시안이 그 둘을
* itinerary · event 로 갈아치웠는데 여기만 남아서, '옛 항구'를 골라도 아이템이
* 하나도 안 붙었다(실측 2026-09-09).
* ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다 — '지역 이야기'(story) 탭이 데이터가
* 있는 것만 골라 그린다. 따로 붙이면 탭 밖에 한 번 더 선다.
*/
defaultSectionTypes: ['event', 'video', 'festival', 'itinerary', 'story'],
},
];
}
@ -121,17 +140,23 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'instagram', name: '인스타그램', checked: true },
],
sections: [
// ★ 백엔드 기본 표(`site_payload._DEFAULT_THEME`)와 id·순서·이름·잠금이 1:1 이어야 한다.
// 어긋나면 에디터에서 본 섹션이 발행본에서 통째로 사라진다.
{ id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '상단 메인 비주얼과 대표 문구' },
{ id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '스테이의 철학과 공간 스토리' },
{ id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' },
{ id: 'event', type: 'event', name: '소식', isLocked: false, isEnabled: true, description: '지금 하는 행사 · 공지' },
{ id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' },
{ id: 'rules', type: 'rules', name: '이용 규정', isLocked: false, isEnabled: true, description: '환불 규정, 입실 수칙 및 에티켓' },
{ id: 'booking', type: 'booking', name: '예약 안내', isLocked: false, isEnabled: true, description: '요금 · 예약 창구 안내' },
{ id: 'video', type: 'video', name: '영상', isLocked: false, isEnabled: true, description: '유튜브 주소 하나면 됩니다' },
{ id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' },
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
{ id: 'festival', type: 'festival', name: '계절별 축제', isLocked: false, isEnabled: true, description: '주변에서 열리는 축제 — 계절로 묶어 보여줍니다' },
{ id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' },
{ id: 'itinerary', type: 'itinerary', name: '추천 일정', isLocked: false, isEnabled: true, description: '숙소에서 출발하는 하루 코스' },
{ id: 'story', type: 'story', name: '지역 이야기', isLocked: false, isEnabled: true, description: '가요·인물·연표·엽서·퀴즈를 탭으로' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
],
templates: templatesFor('stay', '#2563eb', {
name: '옛 항구',

View File

@ -5,7 +5,7 @@ import {publishUrlString, toSlug} from '@o2o/shared';
import {deriveSurfaces} from '@/lib/color';
import {cn} from '@/lib/utils';
import {useBuilderStore, useCurrentTemplate} from '@/stores/builder';
import {resolveVariant} from './canvas/registry';
import {SitePreview} from './SitePreview';
import {PUBLISH_HOST} from '@/lib/site';
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
@ -45,6 +45,7 @@ export function CanvasView() {
const selectedSectionId = useBuilderStore((s) => s.selectedSectionId);
const viewport = useBuilderStore((s) => s.viewport);
const isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
const placeId = useBuilderStore((s) => s.placeId);
const setViewport = useBuilderStore((s) => s.setViewport);
const selectSection = useBuilderStore((s) => s.selectSection);
const setRightTab = useBuilderStore((s) => s.setRightTab);
@ -61,79 +62,12 @@ export function CanvasView() {
// ★ 면 토큰(--tpl-surface / -alt / inverse / border)은 템플릿 색에서 유도한다.
// 이걸 안 내려보내면 SectionFrame 이 폴백(고정 stone 색)으로 떨어져서,
// 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다.
const surfaces = deriveSurfaces(template.colors);
const look = template.look;
const themeVars = {
'--tpl-surface': surfaces.surface,
'--tpl-surface-alt': surfaces.surfaceAlt,
'--tpl-inverse': surfaces.inverse,
'--tpl-border': surfaces.border,
'--tpl-primary': template.colors.primary,
'--tpl-secondary': template.colors.secondary,
'--tpl-bg': template.colors.bg,
'--tpl-card': template.colors.card,
'--tpl-text': template.colors.text,
'--tpl-accent': template.colors.accent,
// 색만 바꾸면 템플릿이 다 같아 보인다 — 생김새를 가르는 건 아래쪽이다.
'--tpl-font-heading': look.fontHeading,
'--tpl-font-body': look.fontBody,
'--tpl-heading-tracking': look.headingTracking,
'--tpl-heading-weight': look.headingWeight,
'--tpl-section-space': look.sectionSpace,
'--tpl-border-width': look.borderWidth,
// ★ Tailwind v4 의 테마 변수를 캔버스 안에서만 덮는다. 이러면 변이 파일 40여 개에 흩어진
// rounded-* · shadow-* 를 하나도 안 고치고 전부 템플릿을 따르게 된다.
// 배수는 Tailwind 기본 비율(0.25/0.375/0.5/0.75/1/1.5rem)을 그대로 옮긴 것이라,
// 기준값이 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다.
'--radius-sm': `calc(${look.radius} * 0.34)`,
'--radius-md': `calc(${look.radius} * 0.5)`,
'--radius-lg': `calc(${look.radius} * 0.67)`,
'--radius-xl': look.radius,
'--radius-2xl': `calc(${look.radius} * 1.34)`,
'--radius-3xl': `calc(${look.radius} * 2)`,
'--shadow-xs': look.shadow,
'--shadow-sm': look.shadow,
'--shadow-md': look.shadow,
} as CSSProperties;
/**
* 섹션 하나를 그린다.
*
* 어떤 레이아웃으로 그릴지는 레지스트리가 정한다 — 여기에 switch 를 두면
* 배리에이션을 하나 추가할 때마다 이 파일을 같이 고쳐야 한다.
/*
* ★ 색·서체 토큰을 여기서 만들지 않는다(2026-09-09).
* 캔버스가 빌더 상태로 --tpl-* 를 따로 만들던 동안, 그 값이 발행본과 갈라졌다 —
* 실측: 편집 캔버스 #ffffff·Pretendard ↔ 발행본 #e4dac0·Gugi.
* 이제 미리보기·편집 둘 다 iframe 안 발행본 렌더러가 그리고, 토큰은 payload 하나에서 온다.
*/
const renderSection = (section: SectionItem) => {
if (!section.isEnabled) return null;
const variant = resolveVariant(section, industry);
// 아직 배리에이션이 없는 섹션 타입 — 캔버스를 깨뜨리지 않고 조용히 건너뛴다.
if (!variant) return null;
const {Component} = variant;
return (
<Component
key={section.id}
section={section}
industryId={industry}
storeName={storeName}
location={location}
weatherLocation={weatherLocation}
template={template}
infoFields={infoFields}
photos={photos}
isSelected={!isPreviewMode && selectedSectionId === section.id}
onSelect={() => {
selectSection(section.id);
setRightTab('content');
}}
/>
);
};
return (
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-muted">
@ -172,10 +106,10 @@ export function CanvasView() {
)}
<div className="flex flex-1 items-start justify-center overflow-y-auto bg-muted/70 p-2 sm:p-6">
<div
style={themeVars}
className={cn('site-canvas my-auto transition-all duration-300', VIEWPORT_FRAME[viewport])}
>
{/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09).
안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다.
바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */}
<div className="my-auto w-full min-w-0 transition-all duration-300">
{viewport !== 'pc' && (
<div className="flex items-center justify-between bg-foreground px-4 py-2 font-mono text-[11px] text-background">
<span className="flex items-center gap-1.5">
@ -190,10 +124,22 @@ export function CanvasView() {
</div>
)}
{/* 섹션은 좌측 패널이 정한 순서 그대로 그린다. */}
<div className="divide-y divide-black/5">
{sections.map((section) => renderSection(section))}
</div>
{/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09).
캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측:
발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다
(gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과
발행된 화면이 서로 다른 물건이었다.
고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */}
<SitePreview
placeId={placeId}
viewport={viewport}
interactive={!isPreviewMode}
selectedId={selectedSectionId}
onSelect={(id) => {
selectSection(id);
setRightTab('content');
}}
/>
<footer
className="border-t border-black/10 p-6 text-center text-xs"

View File

@ -0,0 +1,275 @@
/**
* 붙여넣기 아이템의 **직접 입력** 폼.
*
* ★ 왜 만들었나
* 입구가 JSON 붙여넣기 하나뿐이었다. 한 글자만 고치려 해도 사장님이 중괄호와 쉼표를
* 헤집어야 했고, 쉼표 하나 잘못 지우면 섹션이 통째로 사라졌다. ChatGPT 를 안 쓰는
* 사장님은 아예 채울 수가 없었다.
*
* ★ 폼과 JSON 은 한 값의 두 얼굴이다
* 진실은 `section.data` **문자열 하나**뿐이고 이 폼은 그걸 비춘다. 그래서
* JSON 을 붙여넣으면 폼이 따라 바뀌고, 폼을 고치면 JSON 이 다시 쓰인다 —
* 어느 쪽이 최신인지 물을 일이 없다. 폼 상태를 따로 들고 있으면 그 순간
* "화면은 새 값, 저장은 옛 값"이 생긴다.
*
* ★ 깨진 JSON 은 폼으로 못 편다. 그때는 폼을 감추고 오류만 남긴다 —
* 반쯤 읽힌 값으로 폼을 그리면 사장님이 쓴 걸 덮어쓴다.
*/
import {Plus, Trash2} from 'lucide-react';
import {parseSectionData} from '@o2o/shared';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {cn} from '@/lib/utils';
import type {ItemField, SectionDataSpec} from './canvas/dataSpec';
type Row = Record<string, unknown>;
/** 'source.name' 처럼 점이 있는 키를 읽는다. */
function readAt(row: Row, key: string): unknown {
return key.split('.').reduce<unknown>((node, part) => {
if (node === null || typeof node !== 'object') return undefined;
return (node as Row)[part];
}, row);
}
/** 값을 쓴다. 빈 값이면 **키를 지운다** — 빈 문자열을 남기면 "확인 안 된 값"이 발행본에 나간다. */
function writeAt(row: Row, key: string, value: unknown): Row {
const [head, ...rest] = key.split('.');
const next = {...row};
if (rest.length === 0) {
if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
delete next[head];
} else {
next[head] = value;
}
return next;
}
const child = (typeof next[head] === 'object' && next[head] !== null ? next[head] : {}) as Row;
const written = writeAt(child, rest.join('.'), value);
if (Object.keys(written).length === 0) delete next[head];
else next[head] = written;
return next;
}
function toInput(field: ItemField, value: unknown): string {
if (value === undefined || value === null) return '';
if (field.type === 'tags') return Array.isArray(value) ? value.join(', ') : String(value);
if (field.key === 'turning') return value === true ? '예' : value === false ? '아니오' : '';
return String(value);
}
function fromInput(field: ItemField, text: string): unknown {
const trimmed = text.trim();
if (trimmed === '') return undefined;
if (field.type === 'tags') return trimmed.split(',').map((t) => t.trim()).filter(Boolean);
if (field.type === 'number') {
const n = Number(trimmed.replace(/[^0-9.-]/g, ''));
return Number.isFinite(n) ? n : undefined;
}
if (field.key === 'turning') return trimmed === '예' ? true : trimmed === '아니오' ? false : undefined;
return text;
}
function Field({
field,
row,
onChange,
}: {
field: ItemField;
row: Row;
onChange: (key: string, value: unknown) => void;
}) {
const value = toInput(field, readAt(row, field.key));
const set = (text: string) => onChange(field.key, fromInput(field, text));
return (
<label className={cn('block space-y-1', field.half ? 'sm:col-span-1' : 'col-span-2')}>
<span className="text-[10px] font-semibold text-muted-foreground">{field.label}</span>
{field.options ? (
<select
value={value}
onChange={(event) => set(event.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-[11px] outline-none focus:border-ring"
>
<option value="">—</option>
{field.options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : field.type === 'area' ? (
<textarea
value={value}
onChange={(event) => set(event.target.value)}
rows={2}
className="w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-[11px] leading-relaxed outline-none focus:border-ring"
/>
) : field.type === 'color' ? (
<span className="flex items-center gap-1.5">
<input
type="color"
value={/^#[0-9a-f]{6}$/i.test(value) ? value : '#888888'}
onChange={(event) => set(event.target.value)}
className="h-8 w-9 shrink-0 cursor-pointer rounded border border-input bg-background"
aria-label={field.label}
/>
<Input value={value} onChange={(event) => set(event.target.value)} className="h-8 text-[11px]" />
</span>
) : (
<Input
value={value}
inputMode={field.type === 'number' ? 'numeric' : undefined}
onChange={(event) => set(event.target.value)}
className="h-8 text-[11px]"
/>
)}
{field.hint && <span className="block text-[10px] text-muted-foreground">{field.hint}</span>}
</label>
);
}
export function ItemFormEditor({
spec,
raw,
onChange,
}: {
spec: SectionDataSpec;
raw: string;
onChange: (next: string) => void;
}) {
const parsed = parseSectionData<Row>(spec.kind, raw);
// 깨진 JSON 위에 폼을 그리지 않는다 — 반쯤 읽힌 값으로 덮어쓰면 사장님이 쓴 걸 잃는다.
if (parsed.error) return null;
const envelope = (() => {
try {
const value = JSON.parse(raw || '{}') as Record<string, unknown>;
return typeof value === 'object' && value !== null ? value : {};
} catch {
return {};
}
})();
const items = Array.isArray(envelope.items) ? (envelope.items as Row[]) : [];
/** 항목 배열을 다시 JSON 문자열로. 봉투(kind·title·subtitle)는 그대로 둔다. */
const commit = (next: Row[]) =>
onChange(JSON.stringify({...envelope, kind: spec.kind, items: next}, null, 2));
const patch = (index: number, key: string, value: unknown) =>
commit(items.map((row, i) => (i === index ? writeAt(row, key, value) : row)));
const patchChild = (index: number, childIndex: number, key: string, value: unknown) => {
const childKey = spec.child!.key;
commit(
items.map((row, i) => {
if (i !== index) return row;
const list = Array.isArray(row[childKey]) ? ([...(row[childKey] as Row[])]) : [];
list[childIndex] = writeAt(list[childIndex] ?? {}, key, value);
return {...row, [childKey]: list};
}),
);
};
return (
<div className="space-y-2">
{items.map((row, index) => (
<div key={index} className="space-y-2 rounded-md border border-border bg-background p-2.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-muted-foreground">
{spec.label} {index + 1}
</span>
<button
type="button"
onClick={() => commit(items.filter((_, i) => i !== index))}
className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-destructive"
aria-label={`${index + 1}번째 지우기`}
>
<Trash2 className="size-3" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{spec.fields.map((field) => (
<Field
key={field.key}
field={field}
row={row}
onChange={(key, value) => patch(index, key, value)}
/>
))}
</div>
{spec.child && (
<div className="space-y-1.5 border-t border-border pt-2">
<span className="text-[10px] font-bold text-muted-foreground">
{spec.child.label}
</span>
{(Array.isArray(row[spec.child.key]) ? (row[spec.child.key] as Row[]) : []).map(
(child, childIndex) => (
<div key={childIndex} className="rounded border border-border/70 bg-muted/30 p-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{childIndex + 1}</span>
<button
type="button"
onClick={() =>
patch(
index,
spec.child!.key,
(row[spec.child!.key] as Row[]).filter((_, i) => i !== childIndex),
)
}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive"
aria-label={`${spec.child!.label} ${childIndex + 1} 지우기`}
>
<Trash2 className="size-3" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{spec.child!.fields.map((field) => (
<Field
key={field.key}
field={field}
row={child}
onChange={(key, value) => patchChild(index, childIndex, key, value)}
/>
))}
</div>
</div>
),
)}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 w-full gap-1 text-[10px]"
onClick={() =>
patch(index, spec.child!.key, [
...(Array.isArray(row[spec.child!.key]) ? (row[spec.child!.key] as Row[]) : []),
{},
])
}
>
<Plus className="size-3" />
{spec.child.label} 추가
</Button>
</div>
)}
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-full gap-1 text-[11px]"
onClick={() => commit([...items, {}])}
>
<Plus className="size-3.5" />
{spec.label} 추가
</Button>
</div>
);
}

View File

@ -25,6 +25,7 @@ import {cn} from '@/lib/utils';
import {useBuilderStore, useUnverifiedFields, type RightTab} from '@/stores/builder';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {FaqPanel} from './FaqPanel';
import {ItemFormEditor} from './ItemFormEditor';
import {SectionDesignPanel} from './SectionDesignPanel';
import {resolveVariant} from './canvas/registry';
import {
@ -186,6 +187,13 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT
const [copied, setCopied] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
/**
* 직접 입력 ↔ JSON.
*
* ★ 둘은 같은 값(`section.data`)의 두 얼굴이라 어느 쪽으로 고쳐도 다른 쪽이 따라온다.
* 기본은 **직접 입력**이다 — JSON 을 먼저 보여 주면 대부분의 사장님이 거기서 멈춘다.
*/
const [mode, setMode] = useState<'form' | 'json'>('form');
const section = sections.find((item) => item.id === sectionId);
if (!spec || !section) return null;
@ -225,10 +233,27 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT
<div className="space-y-2 rounded-md border border-border bg-muted/30 p-2.5">
<div className="flex items-center gap-1.5">
<Sparkles className="size-3.5 text-primary" />
<span className="text-xs font-bold">{spec.label} 내용 (JSON)</span>
<span className="text-xs font-bold">{spec.label} 내용</span>
<span className="ml-auto flex rounded-md border border-border p-0.5">
{(['form', 'json'] as const).map((id) => (
<button
key={id}
type="button"
onClick={() => setMode(id)}
aria-pressed={mode === id}
className={cn(
'rounded px-2 py-0.5 text-[10px] font-semibold transition-colors',
mode === id ? 'bg-primary text-primary-foreground' : 'text-muted-foreground',
)}
>
{id === 'form' ? '직접 입력' : 'JSON'}
</button>
))}
</span>
</div>
<p className="text-[10px] leading-relaxed text-muted-foreground">
프롬프트를 복사해 ChatGPT·Claude 에 그대로 붙여넣으면 JSON 을 줍니다. 받은 JSON 을 아래에 붙여넣으세요.
칸을 채워도 되고, 프롬프트를 ChatGPT·Claude 에 붙여넣어 받은 JSON 을 넣어도 됩니다.
어느 쪽으로 고쳐도 다른 쪽이 따라 바뀝니다.
</p>
<div className="flex flex-wrap gap-1.5">
@ -283,14 +308,23 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT
</div>
)}
<textarea
value={raw}
onChange={(event) => updateSectionData(sectionId, event.target.value)}
placeholder={`{ "kind": "${spec.kind}", "items": [ ... ] }`}
rows={10}
spellCheck={false}
className="w-full resize-y rounded-md border border-input bg-background px-2.5 py-2 font-mono text-[11px] leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
{/* 깨진 JSON 위에는 폼을 그리지 않는다(ItemFormEditor 가 null 을 돌려준다) — 그때는 JSON 으로 고쳐야 한다. */}
{mode === 'form' && !parsed.error ? (
<ItemFormEditor
spec={spec}
raw={raw}
onChange={(next) => updateSectionData(sectionId, next)}
/>
) : (
<textarea
value={raw}
onChange={(event) => updateSectionData(sectionId, event.target.value)}
placeholder={`{ "kind": "${spec.kind}", "items": [ ... ] }`}
rows={10}
spellCheck={false}
className="w-full resize-y rounded-md border border-input bg-background px-2.5 py-2 font-mono text-[11px] leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
)}
{parsed.error ? (
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-destructive">

View File

@ -4,27 +4,128 @@
* 사장님은 코드 이름이 아니라 모양으로 고른다. 그래서 카드마다 와이어프레임을 붙이고,
* "언제 이걸 고르면 좋은지"를 한 줄로 적는다.
*/
import {Check, MousePointerClick, Palette, RotateCcw, Shapes} from 'lucide-react';
import {
Check,
ChevronDown,
LayoutTemplate,
MousePointerClick,
Palette,
RotateCcw,
Shapes,
} from 'lucide-react';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {COLOR_PALETTE_PRESETS} from './colorPalettes';
import {TemplatePreview} from './TemplatePreview';
import {resolveVariant, variantsFor} from './canvas/registry';
import {VariantThumb} from './canvas/thumbs';
function ColorPalettePicker() {
/**
* 접히는 묶음.
*
* ★ [디자인] 탭은 290px 한 칸이다. 여기에 템플릿 미리보기 셋 + 팔레트 열두 칸 +
* 이 섹션의 배리에이션이 세로로 쌓이면 스크롤이 세 화면을 넘고, 정작 방금 고른 섹션의
* 배리에이션이 맨 아래로 밀린다. 큰 것(템플릿·색)은 접어 두고 필요할 때 편다.
* ★ `<details>` 다 — 상태를 리액트로 들면 탭을 오갈 때마다 접힘이 초기화된다.
*/
function Group({
title,
icon: Icon,
count,
open,
children,
}: {
title: string;
icon: typeof Palette;
count?: string;
open?: boolean;
children: React.ReactNode;
}) {
return (
<details open={open} className="group border-b border-border pb-3">
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-xs font-bold marker:content-none [&::-webkit-details-marker]:hidden">
<Icon className="size-3.5" />
<span>{title}</span>
{count && <span className="ml-auto font-mono text-[10px] text-muted-foreground">{count}</span>}
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="mt-2 space-y-2">{children}</div>
</details>
);
}
/**
* 템플릿 고르기.
*
* ★ 이 자리가 없었다. 템플릿은 온보딩 4단계에서 한 번 고르면 끝이었고, 에디터의 [디자인] 탭에는
* 팔레트와 섹션 배리에이션만 있었다 — 사장님은 **디자인을 바꾸러 들어와서 디자인을 못 바꿨다.**
* 스토어의 `selectTemplate` 과 서버 저장(`queueSiteTemplateSave`)은 처음부터 있었고 UI 만 없었다.
* ★ 미리보기는 위저드와 **같은 컴포넌트**다(TemplatePreview). 두 벌로 그리면 고를 때 본 것과
* 에디터에서 본 것이 갈린다.
*/
function TemplatePicker({open}: {open?: boolean}) {
const industry = useBuilderStore((s) => s.industry);
const templateId = useBuilderStore((s) => s.templateId);
const placeId = useBuilderStore((s) => s.placeId);
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
const templates = INDUSTRY_CONFIGS[industry].templates;
/**
* ★ 저장된 templateId 가 지금 목록에 **없을 수 있다.** 실제로 있었다 —
* `stay-warm-wood` 처럼 예전 이름이 sites.template_id 에 남아 있으면
* `resolveTemplate` 은 말없이 첫 템플릿으로 떨어지는데, 이 목록에서는 아무것도
* 선택돼 보이지 않아 "고를 수 없는 화면"이 된다. 떨어지는 자리를 여기서도 같게 본다.
*/
const activeId = templates.some((t) => t.id === templateId) ? templateId : templates[0].id;
return (
<Group title="템플릿" icon={LayoutTemplate} count={`${templates.length}종`} open={open}>
<div className="space-y-2">
{templates.map((template) => {
const isActive = activeId === template.id;
return (
<button
key={template.id}
type="button"
onClick={() => {
selectTemplate(template.id);
// 고른 순간 서버에도 남긴다 — 저장 안 하면 새로고침 한 번에 되돌아간다.
queueSiteTemplateSave(placeId, template.id);
}}
aria-pressed={isActive}
className={cn(
'block w-full overflow-hidden rounded-lg border p-2 text-left transition-all',
isActive
? 'border-primary ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50',
)}
>
<span className="flex items-center gap-1.5">
<span className="text-[11px] font-bold">{template.name}</span>
<span className="truncate text-[10px] text-muted-foreground">
{template.toneLabel} · {template.fontStyle}
</span>
{isActive && <Check className="ml-auto size-3 shrink-0 text-primary" />}
</span>
{/* 미리보기는 그 템플릿의 서체·모서리·그림자로 실제로 그린다 — 색 동그라미로는 뭘 고르는지 모른다. */}
<TemplatePreview template={template} />
</button>
);
})}
</div>
</Group>
);
}
function ColorPalettePicker({open}: {open?: boolean}) {
const industry = useBuilderStore((s) => s.industry);
const selectedId = useBuilderStore((s) => s.colorPaletteId);
const selectColorPalette = useBuilderStore((s) => s.selectColorPalette);
const palettes = COLOR_PALETTE_PRESETS.filter((palette) => palette.industry === industry);
return (
<section className="space-y-2 border-b border-border pb-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs font-bold">
<Palette className="size-3.5" /> 컬러 시스템
</span>
<span className="font-mono text-[10px] text-muted-foreground">{palettes.length}종</span>
</div>
<Group title="컬러 시스템" icon={Palette} count={`${palettes.length}종`} open={open}>
<div className="grid grid-cols-2 gap-2">
{palettes.map((palette) => {
const isActive = selectedId === palette.id;
@ -61,7 +162,7 @@ function ColorPalettePicker() {
템플릿 기본 색상으로 되돌리기
</button>
)}
</section>
</Group>
);
}
@ -79,7 +180,8 @@ export function SectionDesignPanel() {
if (!section) {
return (
<div className="flex-1 space-y-4 overflow-y-auto p-3.5">
<ColorPalettePicker />
<TemplatePicker open />
<ColorPalettePicker open />
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<span className="flex size-9 items-center justify-center rounded-full bg-muted text-muted-foreground">
<MousePointerClick className="size-4" />
@ -96,6 +198,8 @@ export function SectionDesignPanel() {
return (
<div className="flex-1 space-y-3 overflow-y-auto p-3.5">
{/* 큰 것부터 좁혀 간다 — 템플릿(전체) → 팔레트(색) → 이 섹션의 레이아웃. */}
<TemplatePicker />
<ColorPalettePicker />
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="flex min-w-0 items-center gap-1.5 text-xs font-bold">

View File

@ -0,0 +1,146 @@
/**
* 미리보기 — 발행본을 **iframe 으로** 띄운다.
*
* ★ 왜 iframe 인가 (2026-09-09)
* 처음엔 발행본 컴포넌트를 빌더 안에 직접 그렸다. 렌더러도 payload 도 토큰도 같게 맞췄고
* 섹션 15개의 계산 스타일(색·서체·여백)까지 동일해졌는데, **레이아웃 폭이 어긋났다.**
* 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은 그 안의 프레임이기 때문이다.
* 실측(1400px 창 · 1024px 프레임, Playwright):
* festival 2560px → 6027px · guide 1168 → 2168 · location 586 → 1135
* 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다. 전체 픽셀 차이 89%.
* "같은 렌더러를 쓴다" 만으로는 안 되고 **뷰포트가 같아야** 한다.
*
* iframe 은 자체 뷰포트를 가진다 — 폭을 390/768/1024 로 주면 발행본이 그 폭에서 보는 것과
* 같은 미디어 쿼리가 걸린다. 해상도 전환도 그제서야 진짜가 된다.
*
* ★ iframe 이 여는 것은 `/preview?placeId=…` — 프리렌더가 굽는 CSR 셸이다
* (`site/scripts/prerender.writePreviewShell`). 그 안에서 발행본 앱이 그대로 돈다.
* ★ 같은 오리진이라 토큰(localStorage)을 iframe 이 그대로 읽는다. 따로 넘기지 않는다.
*/
import {useCallback, useEffect, useRef, useState} from 'react';
import type {ViewportMode} from '@o2o/shared';
/**
* 각 해상도에서 iframe 에 줄 **크기**. 발행본이 그 기기에서 보는 것과 같아야 한다.
*
* ★ 높이도 준다. 폭만 맞추면 `vh` 를 쓰는 자리가 어긋난다 — 히어로가
* `clamp(24rem, 62vh, 36rem)` 이라(`HeroSection`), iframe 이 낮으면 하한 384px 에 걸린다.
* 실측: 발행본 576px ↔ 미리보기 384px. 지연 로딩 이미지도 화면에 덜 들어와 적게 뜬다.
*/
const FRAME_SIZE: Record<ViewportMode, {w: number; h: number}> = {
pc: {w: 1024, h: 800},
tablet: {w: 768, h: 1024},
mobile: {w: 390, h: 844},
};
export function SitePreview({
placeId,
viewport,
interactive = false,
selectedId = null,
onSelect,
}: {
placeId: string | null;
viewport: ViewportMode;
/** 편집 모드 — 섹션을 눌러 고를 수 있게 한다. 미리보기에서는 끈다. */
interactive?: boolean;
selectedId?: string | null;
onSelect?: (sectionId: string) => void;
}) {
const ref = useRef<HTMLIFrameElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
/*
* ★ iframe 은 **진짜 폭(1024·768·390)** 을 유지하고, 자리에 안 들어가면 축소해서 넣는다.
* 폭을 줄여 맞추면 미디어 쿼리가 그 좁은 폭을 보고 발행본과 다른 그리드가 된다 —
* 그걸 피하려고 iframe 을 쓴 것이라 여기서 무너뜨리면 안 된다.
* 기기 미리보기 도구가 쓰는 방식과 같다: 크기는 그대로, 그림만 줄인다.
*/
useEffect(() => {
const box = boxRef.current;
if (!box) return;
const fit = () => {
const avail = box.clientWidth;
setScale(avail > 0 ? Math.min(1, avail / FRAME_SIZE[viewport].w) : 1);
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(box);
return () => ro.disconnect();
}, [viewport]);
/**
* 편집 모드에서 iframe 안 섹션을 고를 수 있게 한다.
*
* ★ 같은 오리진이라 안쪽 문서를 그대로 만질 수 있다. 굳이 postMessage 를 쓰지 않는다.
* ★ 어느 섹션인지는 `data-editor-id` 로 안다 — 화면 id(`gallery`)와 설정 id(`photos`)가
* 달라서, 그 다리를 발행본 렌더러가 놓아 준다(`site/pages/HomePage`).
*/
const wire = useCallback(() => {
const doc = ref.current?.contentDocument;
if (!doc || !interactive) return;
let style = doc.getElementById('editor-outline') as HTMLStyleElement | null;
if (!style) {
style = doc.createElement('style');
style.id = 'editor-outline';
doc.head.appendChild(style);
}
// ★ outline 을 쓴다(border 아님). 상자 크기를 바꾸지 않아 발행본과 레이아웃이 그대로다.
style.textContent = `
[data-editor-id] > * { cursor: pointer; }
[data-editor-id]:hover > * { outline: 2px dashed rgb(59 130 246 / .5); outline-offset: -2px; }
[data-editor-id][data-selected='true'] > * { outline: 2px solid rgb(59 130 246); outline-offset: -2px; }
`;
doc.querySelectorAll<HTMLElement>('[data-editor-id]').forEach((el) => {
el.dataset.selected = String(el.dataset.editorId === selectedId);
});
const onClick = (event: Event) => {
const host = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-editor-id]');
const id = host?.dataset.editorId;
if (id) onSelect?.(id);
};
doc.addEventListener('click', onClick);
return () => doc.removeEventListener('click', onClick);
}, [interactive, selectedId, onSelect]);
useEffect(() => wire(), [wire]);
if (!placeId) {
return (
<div className="text-muted-foreground flex min-h-[40vh] items-center justify-center text-sm">
사업장을 먼저 만들어 주세요
</div>
);
}
const {w, h} = FRAME_SIZE[viewport];
return (
<div ref={boxRef} className="flex w-full justify-center overflow-hidden">
{/* 축소한 만큼 실제 차지하는 크기도 줄여 준다 — 안 그러면 아래쪽에 빈 공간이 남는다. */}
<div style={{width: w * scale, height: h * scale}}>
<iframe
ref={ref}
onLoad={wire}
// ★ key 에 해상도를 넣어 바뀌면 새로 띄운다. 같은 문서를 리사이즈만 하면 이미 지나간
// 미디어 쿼리 분기(그리드 컬럼 수)가 그대로 남는 경우가 있다.
key={viewport}
title="발행본 미리보기"
src={`/preview?placeId=${encodeURIComponent(placeId)}`}
// ★ 발행본과 같은 오리진이라 sandbox 를 걸지 않는다 — 걸면 토큰을 못 읽는다.
className="block shrink-0 border-0 bg-white"
style={{
width: w,
height: h,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
import type {TemplateItem} from '@o2o/shared';
/**
* 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다.
*
* ★ 예전에는 회색 막대 세 줄과 색 동그라미였다. 다섯 템플릿이 전부 같은 그림이라
* 무엇을 고르는지 알 수 없었고, 그래서 아무거나 골랐다.
*/
export function TemplatePreview({template}: {template: TemplateItem}) {
const {look, colors} = template;
return (
<div
className="my-3 overflow-hidden p-3.5"
style={{
backgroundColor: colors.bg,
color: colors.text,
borderRadius: look.radius,
border: `${look.borderWidth} solid ${colors.secondary}22`,
}}
>
<p
className="text-[9px] uppercase"
style={{color: colors.accent, letterSpacing: '0.22em', fontFamily: look.fontBody}}
>
Section
</p>
<p
className="mt-1.5 text-base leading-tight"
style={{
fontFamily: look.fontHeading,
fontWeight: look.headingWeight,
letterSpacing: look.headingTracking,
}}
>
오래 머무는 자리
</p>
<p
className="mt-1.5 text-[10px] leading-relaxed"
style={{fontFamily: look.fontBody, color: colors.secondary}}
>
제목은 {template.fontStyle}, 본문은 이 서체로 나갑니다.
</p>
<div
className="mt-3 p-2"
style={{
backgroundColor: colors.card,
borderRadius: `calc(${look.radius} * 0.67)`,
border: `${look.borderWidth} solid ${colors.secondary}22`,
boxShadow: look.shadow,
}}
>
<span className="text-[9px]" style={{fontFamily: look.fontBody, color: colors.secondary}}>
카드 · 모서리 {look.radius === '0px' ? '각짐' : '둥금'} · 그림자{' '}
{look.shadow === 'none' ? '없음' : '있음'}
</span>
</div>
<span
className="mt-2.5 inline-block px-2.5 py-1 text-[9px] font-semibold"
style={{
backgroundColor: colors.accent,
color: colors.bg,
borderRadius: `calc(${look.radius} * 0.5)`,
fontFamily: look.fontBody,
}}
>
예약 문의
</span>
</div>
);
}

View File

@ -8,6 +8,8 @@
* 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다.
*/
import {SECTION_PROMPT_RULES, SECTION_PROMPTS} from '@o2o/shared';
export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string;
@ -18,8 +20,46 @@ export interface SectionDataSpec {
task: string;
/** 그 아이템에만 걸리는 금지·형식 규칙. */
rules: string;
/**
* 직접 입력 폼의 칸.
*
* ★ 왜 필요한가: JSON 붙여넣기가 유일한 입구였다. 한 글자만 고치고 싶어도 사장님이
* 중괄호와 쉼표를 헤집어야 했고, 쉼표 하나 잘못 지우면 섹션이 통째로 사라졌다.
* ChatGPT 를 안 쓰는 사장님은 아예 못 채운다.
* ★ 폼과 JSON 은 **한 값의 두 얼굴**이다 — 진실은 `section.data` 문자열 하나뿐이고
* 폼은 그걸 비춘다. 그래서 JSON 을 붙여넣으면 폼이 따라 바뀌고, 폼을 고치면 JSON 이
* 다시 쓰인다. 어느 쪽이 최신인지 물을 일이 없다.
*/
fields: ItemField[];
/**
* 항목 안의 목록(정거장 · 시간대 · 들르는 곳).
*
* ★ 이게 없으면 코스·스케줄은 폼으로 못 채운다 — 그 아이템의 알맹이가 전부 이 배열에 있다.
*/
child?: {key: string; label: string; fields: ItemField[]};
}
export interface ItemField {
/** 항목 객체의 키. `@o2o/shared` 의 아이템 타입에 있는 이름 그대로다. */
key: string;
label: string;
/** text = 한 줄 · area = 여러 줄 · number = 숫자 · tags = 쉼표로 끊는 배열 · color = 색 */
type?: 'text' | 'area' | 'number' | 'tags' | 'color';
/** 칸 아래 회색 한 줄. 무엇을 적는 칸인지 애매할 때만 적는다. */
hint?: string;
/** 고르는 값이면 목록. 계절처럼 오탈자가 곧 버그가 되는 칸에 쓴다. */
options?: string[];
/** 두 칸씩 나란히 놓는다 — 짧은 값(연도·시각·분)이 한 줄을 다 먹지 않게. */
half?: boolean;
}
/** 모든 아이템이 공유하는 끝 두 칸 — 출처와 확신. */
const SOURCE_FIELDS: ItemField[] = [
{key: 'verified', label: '확신', type: 'text', options: ['확인', '확인필요'], half: true},
{key: 'source.name', label: '출처 이름', type: 'text', half: true},
{key: 'source.url', label: '출처 주소', type: 'text', hint: '실제로 열리는 공식·기관·언론 페이지'},
];
// ★ 테마 전체 상한이 64KB 다(site_service._THEME_MAX_BYTES). 세 아이템이 각자 다 채우면 거절당하고,
// 거절은 발행 직전에야 드러난다. 그래서 한 섹션당 여기서 먼저 끊는다.
export const SECTION_DATA_MAX_CHARS = 12000;
@ -45,16 +85,7 @@ export function regionOf(location: string): string {
return token ?? location.trim();
}
const PROMPT_RULES = `
[공통 규칙]
1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.
2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.
3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.
4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다. 애매한 걸 "확인" 으로 올리지 않는다.
5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.
6. 설명 문장은 항목당 두 문장을 넘기지 않는다.
7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.
`;
const PROMPT_RULES = SECTION_PROMPT_RULES;
/**
* 붙여넣으면 바로 답이 나오는 프롬프트.
@ -125,23 +156,20 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
null,
2,
),
task: `[해야 할 일]
[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.
1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.
[스키마]
{ "kind":"songs", "version":1, "title":"가요 다방", "subtitle":"...", "items":[
{ "title":"곡명", "artist":"가수", "lyricist":"작사", "composer":"작곡",
"year":1966, "label":"음반사", "labelColor":"#d4551f",
"story":"곡의 배경 (두 문장 이내, 가사 없이)",
"connection":"[업소]와 이 곡을 잇는 한 문장",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.
· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.
· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.`,
task: SECTION_PROMPTS.songs.task,
rules: SECTION_PROMPTS.songs.rules,
fields: [
{key: 'title', label: '곡 제목'},
{key: 'artist', label: '가수', half: true},
{key: 'year', label: '발표 연도', type: 'number', half: true},
{key: 'lyricist', label: '작사', half: true},
{key: 'composer', label: '작곡', half: true},
{key: 'label', label: '음반사', half: true},
{key: 'labelColor', label: '라벨 색', type: 'color', half: true},
{key: 'story', label: '곡 이야기', type: 'area', hint: '가사는 옮기지 않습니다 — 배경만'},
{key: 'connection', label: '우리 가게와의 연결', type: 'area'},
...SOURCE_FIELDS,
],
},
daily: {
@ -203,102 +231,17 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· monthDay 는 MM-DD 다. 연도를 넣지 않는다 — 해마다 다시 쓰는 일력이다.
· 30개를 억지로 채우지 마라. 확실한 것이 12개면 12개만 낸다.
· 같은 장소를 계절만 바꿔 반복하지 않는다.`,
fields: [
{key: 'monthDay', label: '날짜', hint: 'MM-DD. 연도는 넣지 않습니다', half: true},
{key: 'category', label: '분류', half: true},
{key: 'title', label: '제목'},
{key: 'body', label: '내용', type: 'area', hint: '두 문장 이내'},
{key: 'season', label: '계절', options: ['봄', '여름', '가을', '겨울'], half: true},
{key: 'tags', label: '해시태그', type: 'tags', hint: '쉼표로 끊습니다', half: true},
...SOURCE_FIELDS,
],
},
course: {
kind: 'course',
label: '반나절 산책',
sample: JSON.stringify(
{
kind: 'course',
version: 1,
title: '반나절 산책',
items: [
{
name: '탁류길 코스',
duration: '3시간',
startsFrom: '스테이 머뭄',
stops: [
{order: 1, name: '채만식문학관', minutes: 12, note: '금강 하구 옆. 소설의 배경을 먼저 눈에 담습니다.', searchQuery: '군산 채만식문학관'},
{order: 2, name: '군산 내항 부잔교', minutes: 9, note: '조수에 오르내리던 뜬다리. 이 코스의 중심입니다.', searchQuery: '군산 내항 부잔교'},
{order: 3, name: '째보선창', minutes: 7, note: '항구 노동의 기억이 남은 선창입니다.', searchQuery: '군산 째보선창'},
{order: 4, name: '초원사진관', minutes: 11, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관'},
{order: 5, name: '이성당', minutes: 6, note: '코스의 끝. 단팥빵 하나로 반나절을 닫습니다.', searchQuery: '군산 이성당'},
],
verified: '확인',
source: {name: '군산 스탬프투어', url: 'https://www.gunsanstamp.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에서 출발하는 반나절 산책 코스를 2~3개 만든다. 코스마다 정거장 4~6곳.
걸어서 이동할 수 있는 순서로 배열하고, 앞 정거장에서 다음까지 걸리는 분을 minutes 에 적는다.
[스키마]
{ "kind":"course", "version":1, "title":"반나절 산책", "items":[
{ "name":"코스 이름", "duration":"3시간", "startsFrom":"[업소]",
"stops":[ {"order":1,"name":"장소","minutes":12,
"note":"한 문장","searchQuery":"검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· 정거장에 url 을 넣지 않는다. searchQuery(지도 검색어)만 넣는다.
· minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다.
· 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`,
},
schedule: {
kind: 'schedule',
label: '여행 스케줄',
sample: JSON.stringify(
{
kind: 'schedule',
version: 1,
title: '여행 스케줄',
items: [
{
name: '비 오는 날의 하루',
audience: '혼자 온 손님',
season: '장마',
slots: [
{time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'},
{time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'},
{time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'},
{time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에 묵거나 들른 손님이 [지역]에서 하루를 어떻게 보내면 좋을지 여행 스케줄을 2~3개 만든다.
스케줄마다 시간대 5~7개. 아침부터 저녁까지 시각 순서로 배열한다.
스케줄은 서로 성격이 달라야 한다 — 날씨(비 오는 날)·동행(아이와 함께)·계절 중 하나로 가른다.
[스키마]
{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[
{ "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨",
"slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소",
"minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· time 은 24시간 "HH:MM" 로만 적는다. "오전 9시" 처럼 쓰지 않는다.
· 그 장소의 영업시간·휴무일을 안다고 가정하지 않는다. 바뀌면 손님이 헛걸음한다.
· 첫 칸은 [업소]에서 시작하고, 이동은 걸어서 또는 대중교통으로 갈 수 있는 범위로 짠다.
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`,
},
people: {
kind: 'people',
label: '인물 열전',
@ -339,21 +282,17 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
null,
2,
),
task: `[해야 할 일]
[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다.
문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다.
[스키마]
{ "kind":"people", "version":1, "title":"인물 열전", "items":[
{ "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가",
"oneLine":"한 문장 소개", "imageQuery":"사진 검색어",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다.
· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.
· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.`,
task: SECTION_PROMPTS.people.task,
rules: SECTION_PROMPTS.people.rules,
fields: [
{key: 'name', label: '이름'},
{key: 'aka', label: '호 · 예명', half: true},
{key: 'years', label: '생몰년', half: true},
{key: 'role', label: '역할'},
{key: 'oneLine', label: '한 줄 소개', type: 'area'},
{key: 'imageQuery', label: '사진 검색어', hint: '사진 주소는 받지 않습니다 — 검색어만'},
...SOURCE_FIELDS,
],
},
chronicle: {
@ -405,87 +344,16 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
null,
2,
),
task: `[해야 할 일]
[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.
가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.
[스키마]
{ "kind":"chronicle", "version":1, "title":"시간의 골목", "items":[
{ "year":1899, "title":"사건 이름", "summary":"두 문장 이내",
"place":"지금 가 볼 수 있는 자리", "turning":true,
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.`,
},
literature: {
kind: 'literature',
label: '문학 서가',
sample: JSON.stringify(
{
kind: 'literature',
version: 1,
title: '문학 서가',
subtitle: '이 도시를 쓴 책들',
items: [
{
workTitle: '탁류',
author: '채만식',
year: '1937–1938 연재',
genre: '장편소설',
spineColor: '#7d3320',
background: '일제강점기 군산을 무대로 한 여자의 삶과 도시의 경제를 겹쳐 놓았습니다.',
whyHere: '소설 속 거리를 따라 걸으면 지금의 내항이 겹쳐 보입니다.',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
workTitle: '레디메이드 인생',
author: '채만식',
year: '1934',
genre: '단편소설',
spineColor: '#2a4a6b',
background: '배운 사람이 일자리를 얻지 못하는 식민지 도시의 앞뒤를 풍자로 잘라 보입니다.',
whyHere: '같은 작가의 문학관이 금강 하구 옆에 서 있습니다.',
verified: '확인필요',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
workTitle: '아리랑',
author: '조정래',
year: '대하소설',
genre: '장편소설',
spineColor: '#3f5c33',
background: '이 지역이 겪은 시간을 인물들의 이동으로 따라갑니다.',
whyHere: '스탬프투어 아리랑길이 소설과 같은 자리를 지납니다.',
verified: '확인필요',
source: {name: '군산 스탬프투어', url: 'https://www.gunsanstamp.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역]을 배경으로 하거나 [지역]에서 쓰인 문학 작품을 8편까지 찾는다.
소설·시·수필을 고루 섞고, 작품이 그 지역과 어떻게 닿는지 한 문장으로 적는다.
[스키마]
{ "kind":"literature", "version":1, "title":"문학 서가", "items":[
{ "workTitle":"작품명", "author":"작가", "year":"1937", "genre":"장편소설",
"spineColor":"#7d3320", "background":"작품 배경 두 문장 이내",
"whyHere":"[지역]과 잇는 한 문장",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· 작품 원문(구절·시행·문장)을 옮기지 않는다. 줄거리와 배경만 쓴다.
· 저작권이 살아 있는 작품일수록 짧게 쓴다.
· spineColor 는 책등 색이다. 작품 분위기에 맞는 어두운 색 하나를 hex 로 고른다.`,
task: SECTION_PROMPTS.chronicle.task,
rules: SECTION_PROMPTS.chronicle.rules,
fields: [
{key: 'year', label: '연도', type: 'number', half: true},
{key: 'place', label: '지금 이 자리', half: true},
{key: 'title', label: '무슨 일'},
{key: 'summary', label: '한 줄 설명', type: 'area'},
{key: 'turning', label: '전환점', options: ['예', '아니오'], hint: '붉은 점으로 표시됩니다'},
...SOURCE_FIELDS,
],
},
postcard: {
@ -527,21 +395,15 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
null,
2,
),
task: `[해야 할 일]
[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.
사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.
[스키마]
{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[
{ "line":"한 문장", "hashtags":["#태그"], "place":"장소",
"postmark":"소인에 찍을 짧은 지명",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.`,
task: SECTION_PROMPTS.postcard.task,
rules: SECTION_PROMPTS.postcard.rules,
fields: [
{key: 'line', label: '엽서 문장', type: 'area'},
{key: 'place', label: '장소', half: true},
{key: 'postmark', label: '소인 지명', half: true},
{key: 'hashtags', label: '해시태그', type: 'tags', hint: '쉼표로 끊습니다 · 3개까지'},
...SOURCE_FIELDS,
],
},
quiz: {
@ -583,110 +445,227 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
null,
2,
),
task: `[해야 할 일]
[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다.
질문은 검색하면 바로 나오는 단답형이 아니라 "왜" 와 "어떻게" 를 묻는 것으로 한다.
[스키마]
{ "kind":"quiz", "version":1, "title":"뒤집어 보는 질문", "items":[
{ "question":"질문 한 문장", "hint":"두 문장 이내 힌트",
"topic":"관련 장소·주제", "level":"초등|중등|어른",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.
· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.
· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.`,
task: SECTION_PROMPTS.quiz.task,
rules: SECTION_PROMPTS.quiz.rules,
fields: [
{key: 'question', label: '질문', type: 'area'},
{key: 'hint', label: '힌트', type: 'area', hint: '정답은 두지 않습니다'},
{key: 'topic', label: '주제', half: true},
{key: 'level', label: '난이도', half: true},
...SOURCE_FIELDS,
],
},
planner: {
kind: 'planner',
label: '계절별 추천 하루',
video: {
kind: 'video',
label: '영상',
sample: JSON.stringify(
{
kind: 'planner',
kind: 'video',
version: 1,
title: '계절별 추천 하루',
subtitle: '계절을 고르면 시각까지 짜 드립니다',
title: '영상으로 보기',
subtitle: '사진으로 다 안 보이는 것',
items: [
{
name: '개항장 골목 한 바퀴',
season: '가을',
rank: 1,
startTime: '09:30',
audience: '처음 온 손님',
why: '해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.',
stops: [
{name: '군산근대역사박물관', minutes: 90, moveMinutes: 15, note: '항구 도시가 어떻게 만들어졌는지 먼저 봅니다.', searchQuery: '군산근대역사박물관'},
{name: '초원사진관', minutes: 30, moveMinutes: 10, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관'},
{name: '이성당', minutes: 40, moveMinutes: 8, note: '단팥빵 하나로 오후를 엽니다.', searchQuery: '군산 이성당'},
{name: '경암동 철길마을', minutes: 60, moveMinutes: 20, note: '해 질 무렵 빛이 가장 좋습니다.', searchQuery: '군산 경암동 철길마을'},
],
url: 'https://youtube.com/shorts/c2ZdwhaB7S4',
caption: '마당에서 본 저녁 무렵',
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
name: '섬으로 건너가는 날',
season: '가을',
rank: 2,
startTime: '10:00',
audience: '차를 가져온 손님',
why: '바람이 차기 전 마지막으로 바다가 열리는 계절입니다.',
stops: [
{name: '고군산군도 선유도', minutes: 150, moveMinutes: 50, note: '다리로 건너갑니다. 차로 이동.', searchQuery: '군산 선유도'},
{name: '장자도', minutes: 60, moveMinutes: 15, note: '섬과 섬 사이를 걸어 넘습니다.', searchQuery: '군산 장자도'},
],
verified: '확인필요',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
{
name: '비 오면 여기로',
season: '여름',
rank: 1,
startTime: '10:30',
audience: '혼자 온 손님',
why: '장마철에 실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.',
stops: [
{name: '군산근대건축관', minutes: 60, moveMinutes: 12, note: '옛 은행 건물 안이 통째로 전시입니다.', searchQuery: '군산근대건축관'},
{name: '한일옥', minutes: 60, moveMinutes: 8, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{name: '째보선창 근처 카페', minutes: 90, moveMinutes: 10, note: '비 그칠 때까지 앉아 있기 좋습니다.', searchQuery: '군산 째보선창 카페'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에 묵는 손님을 위한 [지역] 하루 코스를 **계절마다 1~3위**로 추천한다.
계절 넷(봄·여름·가을·겨울)을 모두 채우고, 계절마다 성격이 다른 코스 세 개를 1·2·3위로 매긴다.
코스마다 정거장 3~5곳을 **도는 순서대로** 적는다.
task: `
[해야 할 일]
[업소]의 유튜브 영상 주소를 1~3개 고른다.
★ 시각은 적지 않는다. 출발 시각(startTime)과 각 정거장의 머무는 시간(minutes)·이동 시간(moveMinutes)만
적으면 화면이 시각을 계산해 채운다.
[스키마]
{ "kind":"planner", "version":1, "title":"계절별 추천 하루", "items":[
{ "name":"코스 이름", "season":"봄|여름|가을|겨울", "rank":1,
"startTime":"09:30", "audience":"누구에게 맞는 하루",
"why":"왜 이 계절에 이 코스인가 (한 문장)",
"stops":[ {"name":"장소","minutes":90,"moveMinutes":15,
"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
· 이미 올라가 있는 영상의 **주소만** 옮긴다. 영상을 만들라는 것이 아니다.
· 손님이 사진으로는 알 수 없는 것을 보여주는 영상이 좋다 — 공간의 크기, 빛, 소리, 가는 길.`,
rules: `
[이 아이템만의 규칙]
· rank 는 계절마다 1·2·3 만 쓴다. 4위는 만들지 않는다 — 아무도 안 고른다.
· 같은 계절의 세 코스가 서로 달라야 한다. 정거장이 겹치면 순위가 아니라 중복이다.
· moveMinutes 는 앞 정거장에서 오는 시간이다. 첫 정거장은 [업소]에서 나서는 시간이다.
· 걸어서 못 가는 곳은 note 에 "차로 이동" 이라고 적는다.
· 영업시간·휴무일·요금은 넣지 않는다. 바뀌면 짜 준 일정이 손님을 헛걸음시킨다.
· 장소에 url 을 넣지 않는다. searchQuery(지도 검색어)만 넣는다.`,
· url 은 유튜브만 받는다. watch · youtu.be · shorts 어느 형식이든 된다.
· 다른 사이트 영상 주소를 넣으면 재생되지 않고 링크로만 남는다.
· caption 은 한 줄이다. 영상 제목을 그대로 옮기지 않는다 — 왜 볼 만한지를 적는다.
· 남의 영상을 넣지 않는다. 업소가 올렸거나 업소를 찍은 영상만.`,
fields: [
{key: 'url', label: '유튜브 주소', hint: 'watch · youtu.be · shorts 다 됩니다'},
{key: 'caption', label: '한 줄 설명', hint: '제목 말고 왜 볼 만한지'},
...SOURCE_FIELDS,
],
},
event: {
kind: 'event',
label: '소식',
sample: JSON.stringify(
{
kind: 'event',
version: 1,
title: '소식',
items: [
{
title: '가을 평일 2박 할인',
kind: '이벤트',
startDate: '2026-09-01',
endDate: '2026-10-31',
summary: '평일 2박 이상 예약하시면 1박 요금의 20%를 빼 드립니다.',
howTo: "예약 시 요청사항에 '가을 평일' 이라고 적어 주세요.",
postUrl: 'https://www.instagram.com/p/xxxxxxxxxxx/',
verified: '확인',
},
{
title: '10월 정기 휴무 안내',
kind: '공지',
startDate: '2026-10-14',
endDate: '2026-10-16',
body: '설비 점검으로 사흘간 쉽니다. 이 기간 예약은 받지 않습니다.',
verified: '확인',
},
],
},
null,
2,
),
/*
* ★ 이 아이템만 프롬프트가 다르다. 나머지는 지역을 리서치해 오는 것이지만
* 소식은 **이 가게에서 지금 실제로 하는 일**이다 — 모델이 알 수 없고, 지어내면
* 손님이 없는 행사를 보고 찾아온다. 그래서 "찾아라" 가 아니라 "옮겨 적어라" 다.
*/
task: `[해야 할 일]
아래 붙여넣은 인스타그램 게시물·공지 글을 소식 JSON 으로 **옮겨 적는다.** 찾지 말고 옮긴다.
[여기에 원문을 붙여넣으세요]
[스키마]
{ "kind":"event", "version":1, "title":"소식", "items":[
{ "title":"소식 제목", "kind":"이벤트|공지",
"startDate":"2026-09-01", "endDate":"2026-10-31",
"summary":"카드에 보일 한두 문장", "body":"긴 본문(공지처럼 여러 줄일 때만)",
"howTo":"어떻게 참여하나",
"postUrl":"https://www.instagram.com/p/...",
"verified":"확인|확인필요" } ] }`,
rules: `
[이 아이템만의 규칙]
· 원문에 없는 날짜·혜택·조건을 만들지 않는다. 안 적혀 있으면 그 필드를 뺀다.
· 기간이 지난 소식은 넣지 않는다 — 끝난 행사를 걸어 두면 손님이 헛걸음한다.
· postUrl 은 붙여넣은 원문의 주소 그대로다. 없으면 넣지 않는다.
· 사장님이 직접 옮긴 것이므로 verified 는 "확인" 이다.
`,
fields: [
{key: 'title', label: '제목'},
{key: 'kind', label: '종류', type: 'text', options: ['이벤트', '공지'], half: true},
{key: 'startDate', label: '시작일', type: 'text', hint: '2026-09-01', half: true},
{key: 'endDate', label: '종료일', type: 'text', hint: '2026-10-31', half: true},
{key: 'summary', label: '요약', type: 'area', hint: '카드에 보이는 한두 문장'},
{key: 'body', label: '본문', type: 'area', hint: '공지처럼 여러 줄일 때만'},
{key: 'howTo', label: '참여 방법', type: 'area'},
{key: 'postUrl', label: '원문 주소', type: 'text'},
...SOURCE_FIELDS,
],
},
itinerary: {
kind: 'itinerary',
label: '추천 일정',
sample: JSON.stringify(
{
kind: 'itinerary',
version: 1,
title: '추천 일정',
subtitle: '며칠 묵느냐에 따라',
items: [
{
name: '개항장 골목 한 바퀴',
duration: '반나절',
audience: '처음 온 손님',
why: '걷는 거리가 가장 짧고, 군산이 어떤 도시였는지 먼저 보게 됩니다.',
startTime: '09:30',
stops: [
{name: '군산근대역사박물관', minutes: 90, moveMinutes: 12, note: '항구 도시가 어떻게 만들어졌는지 먼저 봅니다.', searchQuery: '군산근대역사박물관', latitude: 35.9908197098, longitude: 126.7121231556},
{name: '초원사진관', minutes: 30, moveMinutes: 10, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관', latitude: 35.9877369536, longitude: 126.7083826065},
{name: '이성당', minutes: 40, moveMinutes: 8, note: '단팥빵 하나로 오후를 엽니다.', searchQuery: '군산 이성당', latitude: 35.9870443303, longitude: 126.7111681428},
],
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
name: '섬까지 다녀오는 1박 2일',
duration: '1박 2일',
audience: '차를 가져온 손님',
why: '첫날은 도심, 이튿날은 바다. 하루씩 성격이 다릅니다.',
days: [
{
label: '첫째 날',
startTime: '14:00',
stops: [
{name: '경암동 철길마을', minutes: 40, moveMinutes: 15, searchQuery: '군산 경암동 철길마을', latitude: 35.9813520474, longitude: 126.7362330582},
{name: '째보선창', minutes: 40, moveMinutes: 12, note: '해 지는 시간에 맞추면 좋습니다.', searchQuery: '군산 째보선창', latitude: 35.9875345, longitude: 126.7199875},
],
},
{
label: '둘째 날',
startTime: '09:30',
stops: [
{name: '고군산군도 선유도', minutes: 180, moveMinutes: 45, note: '다리로 건너갑니다. 차로 이동.', searchQuery: '군산 선유도', latitude: 35.8102049733, longitude: 126.4124052922},
{name: '장자도', minutes: 60, moveMinutes: 12, searchQuery: '군산 장자도', latitude: 35.8101, longitude: 126.3973},
],
},
],
verified: '확인필요',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: `
[해야 할 일]
[업소]에서 출발하는 추천 일정을 3~5개 만든다. **반나절 · 1박 2일 · 2박 3일** 을 섞는다.
· 하루짜리는 stops 만 쓴다. 여러 날이면 days 로 날짜를 나눈다.
· 정거장은 하루에 3~5곳. 여섯 곳부터는 아무도 그대로 못 돈다.
· minutes 는 거기서 머무는 시간, moveMinutes 는 앞 칸에서 오는 데 걸리는 시간이다.
· 첫 정거장의 moveMinutes 는 **업소에서 나서는 시간**이다.`,
rules: `
[이 아이템만의 규칙]
· 순위를 매기지 않는다. rank 같은 칸은 없다 — 어느 일정이 1위인지는 정할 일이 아니다.
· duration 은 "반나절" · "1박 2일" · "2박 3일" 중 하나로 적는다. 목록이 이 값으로 갈린다.
· season 은 **꼭 필요할 때만** 적는다. 적으면 그 계절에만 손님 화면에 나간다
(벚꽃·해수욕처럼 계절을 타는 일정에만).
· 링크를 만들지 않는다. searchQuery 에 지도 검색어만 적는다.
· latitude · longitude 는 **아는 곳만** 적는다. 지도에 번호 핀이 찍히는 값이다 —
지어내면 엉뚱한 동네에 핀이 찍히고, 비워 두면 그 칸은 시간표에만 선다(그게 낫다).
· 밤 9시를 넘기는 칸은 화면에서 빠진다 — 시각을 계산해 보고 넣는다.`,
fields: [
{key: 'name', label: '일정 이름'},
{key: 'duration', label: '기간', options: ['반나절', '1박 2일', '2박 3일'], half: true},
{key: 'startTime', label: '출발 시각', hint: 'HH:MM · 하루짜리만', half: true},
{key: 'audience', label: '누구에게 맞는 일정', half: true},
{
key: 'season',
label: '계절',
half: true,
options: ['봄', '여름', '가을', '겨울'],
hint: '적으면 그 계절에만 나갑니다',
},
{key: 'why', label: '왜 이 일정인가', type: 'area'},
...SOURCE_FIELDS,
],
child: {
key: 'stops',
label: '정거장',
fields: [
{key: 'name', label: '장소'},
{key: 'minutes', label: '머무는 시간(분)', type: 'number', half: true},
{key: 'moveMinutes', label: '오는 데 걸리는 시간(분)', type: 'number', half: true},
{key: 'searchQuery', label: '지도 검색어'},
{key: 'note', label: '한 줄', type: 'area'},
],
},
},
};
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {

View File

@ -0,0 +1,205 @@
import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
import {ArrowUpRight, ChevronLeft, ChevronRight} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import type {GuideCard} from '../variants/local/types';
import {walkMinutes} from '../variants/local/walking';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
/**
* 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기".
*
* ★ 사진이 없으면 회색 판에 이름을 크게 쓴다(스크린샷의 '군산복집' 카드). 자리를 비우거나
* 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다.
* ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다.
*/
function GuideCardView({card, colors, leading}: {card: GuideCard; colors: TemplateItem['colors']; leading?: ReactNode}) {
const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined;
return (
<a
href={naverSearch(card.searchQuery)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="group flex w-[240px] shrink-0 snap-start flex-col overflow-hidden rounded-lg border border-stone-200/80 bg-white shadow-2xs transition-shadow hover:shadow-md sm:w-[260px]"
>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-stone-200/70">
{card.imageUrl ? (
<img
src={card.imageUrl}
alt={card.name}
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
) : (
<div className="flex size-full items-center justify-center px-4 text-center">
<span className="serif-title text-base font-bold text-stone-700">{card.name}</span>
</div>
)}
{minutes !== undefined && (
<span className="absolute bottom-2.5 left-2.5 inline-flex items-center gap-1.5 rounded bg-stone-900/80 px-2 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
<span>도보 약 {minutes}분</span>
{card.distanceText && <span className="font-mono font-normal text-white/75">{card.distanceText}</span>}
</span>
)}
</div>
<div className="flex flex-1 flex-col gap-1.5 p-3.5">
{leading}
<h4 className="text-sm font-bold text-stone-900" style={{color: colors.text}}>
{card.name}
</h4>
{card.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-stone-500">{card.description}</p>
)}
<span className="mt-auto flex items-center gap-0.5 pt-1 text-[11px] font-medium text-stone-400 group-hover:text-stone-900">
검색으로 열기
<ArrowUpRight className="size-3" />
</span>
</div>
</a>
);
}
/**
* 카드 캐러셀 — 가로 스크롤(스냅) + 좌우 화살표 + 점 페이지네이션 + "1 / N".
*
* ★ 페이지 = 한 화면에 온전히 들어가는 카드 수. 미리보기 해상도(PC/태블릿/모바일)가 바뀌면
* ResizeObserver 가 다시 센다 — 고정 4장으로 두면 모바일에서 점이 카드 수와 어긋난다.
* ★ 화살표는 컨테이너 폭만큼 넘긴다(한 페이지). 한 장씩 넘기면 24장에 화살표 23번이다.
*/
export function PlaceCarousel({
cards,
colors,
renderLeading,
}: {
cards: GuideCard[];
colors: TemplateItem['colors'];
/** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */
renderLeading?: (card: GuideCard) => ReactNode;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [perPage, setPerPage] = useState(1);
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil(cards.length / perPage));
// 카드 한 장 폭(gap 포함)으로 한 화면에 몇 장 들어가는지 잰다.
const measure = useCallback(() => {
const track = trackRef.current;
const first = track?.firstElementChild as HTMLElement | null;
if (!track || !first) return;
const gap = parseFloat(getComputedStyle(track).columnGap || '0') || 0;
const step = first.offsetWidth + gap;
const fit = Math.max(1, Math.floor((track.clientWidth + gap) / step));
setPerPage(fit);
setPage(Math.min(Math.round(track.scrollLeft / (step * fit)), Math.max(0, Math.ceil(cards.length / fit) - 1)));
}, [cards.length]);
useEffect(() => {
measure();
const track = trackRef.current;
if (!track) return;
const ro = new ResizeObserver(measure);
ro.observe(track);
return () => ro.disconnect();
}, [measure]);
// 필터가 바뀌어 카드가 줄면 첫 페이지로.
useEffect(() => {
trackRef.current?.scrollTo({left: 0});
setPage(0);
}, [cards]);
const scrollToPage = (next: number) => {
const track = trackRef.current;
if (!track) return;
const clamped = Math.max(0, Math.min(pages - 1, next));
track.scrollTo({left: clamped * track.clientWidth, behavior: 'smooth'});
setPage(clamped);
};
const onScroll = () => {
const track = trackRef.current;
if (!track || track.clientWidth === 0) return;
setPage(Math.max(0, Math.min(pages - 1, Math.round(track.scrollLeft / track.clientWidth))));
};
const arrow =
'flex size-9 cursor-pointer items-center justify-center rounded-full border transition-colors disabled:cursor-default disabled:opacity-30';
return (
<div className="space-y-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
aria-label="이전"
disabled={page === 0}
onClick={(e) => {
e.stopPropagation();
scrollToPage(page - 1);
}}
className={cn(arrow, 'border-stone-300/70 bg-white/70 text-stone-500 hover:bg-white')}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
aria-label="다음"
disabled={page >= pages - 1}
onClick={(e) => {
e.stopPropagation();
scrollToPage(page + 1);
}}
style={{borderColor: colors.text, color: colors.text}}
className={cn(arrow, 'bg-white hover:bg-stone-50')}
>
<ChevronRight className="size-4" />
</button>
</div>
<div
ref={trackRef}
onScroll={onScroll}
className="scrollbar-none flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1"
>
{cards.map((card) => (
<GuideCardView
key={`${card.name}-${card.distanceMeters ?? ''}`}
card={card}
colors={colors}
leading={renderLeading?.(card)}
/>
))}
</div>
{pages > 1 && (
<div className="flex items-center justify-center gap-3">
<div className="flex items-center gap-1.5">
{Array.from({length: pages}, (_, i) => (
<button
key={i}
type="button"
aria-label={`${i + 1}페이지`}
onClick={(e) => {
e.stopPropagation();
scrollToPage(i);
}}
className={cn(
'h-1.5 cursor-pointer rounded-full transition-all',
i === page ? 'w-5' : 'w-1.5 bg-stone-300 hover:bg-stone-400',
)}
style={i === page ? {backgroundColor: colors.text} : undefined}
/>
))}
</div>
<span className="font-mono text-[11px] text-stone-400">
{page + 1} / {pages}
</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,234 @@
/**
* 캔버스의 가로 슬라이더.
*
* ★ 왜 라이브러리인가 (2026-09-02)
* 예전에는 `overflow-x: auto` 상자 + 화살표 버튼 두 개였다. 화살표는 `scrollBy` 를 불러
* 한 화면의 80%씩 밀었는데, **카드를 잡아끄는 방법이 없었다** — 휠 마우스 사용자는
* 화살표를 찾지 못하면 옆에 더 있다는 걸 몰랐고, 트랙패드에서만 자연스러웠다.
* embla-carousel 은 의존성 0, 코어 ~5KB(gzip)이고 발행본(solution/site)이 쓰는 것과 같다 —
* 미리보기와 발행본의 조작감이 갈리면 미리보기가 아니다.
*
* ★ 캔버스 전용 사정: 카드 안에 링크·버튼이 있다. 드래그를 시작한 뒤의 클릭은 삼킨다
* (embla 의 `pointerUp` 뒤 click 은 막지 않으면 카드가 눌린 것으로 처리된다).
*/
import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import {ChevronLeft, ChevronRight} from 'lucide-react';
import {ITEM_BORDER, ITEM_CARD} from '../variants/items/common';
export function Rail({
label,
children,
tone = 'light',
gap = 1,
className,
nav = 'above',
viewportClassName,
trackClassName = 'items-start',
}: {
/** 스크린리더가 읽을 이름. 화살표의 aria-label 도 여기서 만든다. */
label: string;
children: ReactNode;
tone?: 'light' | 'dark';
/** 슬라이드 사이 간격(rem). */
gap?: number;
className?: string;
/**
* 화살표를 어디에 둘지. 슬라이더가 액자(필름·책장) 안에 들어가는 아이템은
* 'below' 로 액자 밖에 세운다 — 액자 안에 넣으면 질감 위에 버튼이 떠서 물건처럼 안 보인다.
*/
nav?: 'above' | 'below' | 'overlay' | 'none';
/** 창(viewport)에 붙일 클래스. 액자 안쪽 여백처럼 슬라이더 자체가 가져야 하는 값. */
viewportClassName?: string;
/** 트랙에 붙일 클래스. 세로 정렬(items-end 같은)이 아이템마다 다르다. */
trackClassName?: string;
}) {
const [emblaRef, embla] = useEmblaCarousel({align: 'start', containScroll: 'trimSnaps'});
const [snaps, setSnaps] = useState<number[]>([]);
const [selected, setSelected] = useState(0);
const [canPrev, setCanPrev] = useState(false);
const [canNext, setCanNext] = useState(false);
const viewport = useRef<HTMLDivElement | null>(null);
const dragged = useRef(false);
useEffect(() => {
if (!embla) return;
const sync = () => {
setSelected(embla.selectedScrollSnap());
setCanPrev(embla.canScrollPrev());
setCanNext(embla.canScrollNext());
};
const measure = () => {
setSnaps(embla.scrollSnapList());
sync();
};
measure();
embla.on('select', sync);
embla.on('reInit', measure);
viewport.current?.setAttribute('data-slider', 'on');
return () => {
embla.off('select', sync);
embla.off('reInit', measure);
};
}, [embla]);
// 끌고 나서 손을 떼면 그 자리의 카드가 클릭된 것으로 처리된다 — 한 번만 삼킨다.
useEffect(() => {
if (!embla) return;
const down = () => {
dragged.current = false;
viewport.current?.setAttribute('data-dragging', 'true');
};
const move = () => {
dragged.current = true;
};
const up = () => viewport.current?.removeAttribute('data-dragging');
embla.on('pointerDown', down);
embla.on('scroll', move);
embla.on('pointerUp', up);
return () => {
embla.off('pointerDown', down);
embla.off('scroll', move);
embla.off('pointerUp', up);
};
}, [embla]);
const prev = useCallback(() => embla?.scrollPrev(), [embla]);
const next = useCallback(() => embla?.scrollNext(), [embla]);
// 한 화면에 다 들어가면 조작부를 그리지 않는다 — 눌러도 안 움직이는 버튼은 고장으로 읽힌다.
const movable = snaps.length > 1;
// 액자(필름·책장) 안에 든 슬라이더는 화살표를 창 위에 겹친다 — 액자 밖에 두면
// 어느 줄을 미는 버튼인지 알 수 없고, 액자 안에 한 줄 더 두면 질감이 끊긴다.
const overlay = movable && nav === 'overlay' && (
<>
<EdgeButton dir="prev" tone={tone} onClick={prev} disabled={!canPrev} label={label} />
<EdgeButton dir="next" tone={tone} onClick={next} disabled={!canNext} label={label} />
</>
);
const controls = movable && (nav === 'above' || nav === 'below') && (
<div className={`flex items-center justify-end gap-1.5 ${nav === 'above' ? 'mb-2' : 'mt-2'}`}>
<span className="mr-auto text-[11px] tabular-nums opacity-50">
{selected + 1} / {snaps.length}
</span>
<NavButton dir="prev" tone={tone} onClick={prev} disabled={!canPrev} label={label} />
<NavButton dir="next" tone={tone} onClick={next} disabled={!canNext} label={label} />
</div>
);
return (
<div className={`${nav === 'overlay' ? 'relative' : ''} ${className ?? ''}`}>
{nav === 'above' && controls}
<div
ref={(node) => {
viewport.current = node;
emblaRef(node);
}}
className={`w4-scroll ${viewportClassName ?? ''}`}
role="group"
aria-roledescription="캐러셀"
aria-label={label}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
prev();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
next();
}
}}
onClickCapture={(event) => {
if (!dragged.current) return;
event.preventDefault();
event.stopPropagation();
dragged.current = false;
}}
>
<div className={`flex ${trackClassName}`} style={{gap: `${gap}rem`}}>
{children}
</div>
</div>
{nav === 'below' && controls}
{overlay}
</div>
);
}
function EdgeButton({
dir,
onClick,
disabled,
tone,
label,
}: {
dir: 'prev' | 'next';
onClick: () => void;
disabled: boolean;
tone: 'light' | 'dark';
label: string;
}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
const dark = tone === 'dark';
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
disabled={disabled}
aria-label={`${label} ${dir === 'prev' ? '이전' : '다음'}`}
className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full backdrop-blur transition-opacity hover:opacity-80 disabled:opacity-0 ${
dir === 'prev' ? 'left-1.5' : 'right-1.5'
}`}
style={{
backgroundColor: dark
? 'color-mix(in oklab, currentColor 22%, transparent)'
: 'color-mix(in oklab, currentColor 12%, transparent)',
}}
>
<Icon className="size-4" />
</button>
);
}
function NavButton({
dir,
onClick,
disabled,
tone,
label,
}: {
dir: 'prev' | 'next';
onClick: () => void;
disabled: boolean;
tone: 'light' | 'dark';
label: string;
}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
const dark = tone === 'dark';
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
disabled={disabled}
aria-label={`${label} ${dir === 'prev' ? '이전' : '다음'}`}
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70 disabled:opacity-25"
style={{
borderColor: dark ? 'color-mix(in oklab, currentColor 35%, transparent)' : ITEM_BORDER,
backgroundColor: dark ? 'transparent' : ITEM_CARD,
}}
>
<Icon className="size-4" />
</button>
);
}

View File

@ -0,0 +1,58 @@
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import {
WALK_FILTERS,
matchesWalkFilter,
type WalkFilterKey,
} from '../variants/local/walking';
/**
* 도보 시간 필터 탭 — "전체 24 · 걸어서 5분 이내 8 · 10분 이내 19 · 10분 이상 5".
*
* ★ 구간은 누적이다(walking.ts 주석). 그래서 숫자가 서로 더해져 전체가 되지 않는다 —
* "5분 이내" ⊂ "10분 이내". 배타 구간으로 바꾸면 라벨("이내")과 숫자가 어긋난다.
* ★ 상태는 부르는 쪽(카테고리 섹션)이 든다. 섹션마다 필터가 따로 움직여야 한다.
*/
export function WalkFilterTabs({
distances,
value,
onChange,
colors,
}: {
/** 항목별 거리(m). 모르는 항목은 undefined — '전체'에만 센다. */
distances: (number | undefined)[];
value: WalkFilterKey;
onChange: (key: WalkFilterKey) => void;
colors: TemplateItem['colors'];
}) {
return (
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="도보 시간 필터">
{WALK_FILTERS.map(({key, label}) => {
const count = distances.filter((m) => matchesWalkFilter(key, m)).length;
const active = key === value;
return (
<button
key={key}
type="button"
role="tab"
aria-selected={active}
onClick={(e) => {
e.stopPropagation();
onChange(key);
}}
style={active ? {backgroundColor: colors.text, borderColor: colors.text} : undefined}
className={cn(
'flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors',
active
? 'text-white'
: 'border-stone-300/80 bg-white/70 text-stone-700 hover:bg-white',
)}
>
<span>{label}</span>
<span className={cn('font-mono text-[11px]', active ? 'text-white/80' : 'text-stone-400')}>{count}</span>
</button>
);
})}
</div>
);
}

View File

@ -17,3 +17,7 @@ export {AddressCard} from './AddressCard';
export {FeatureCard} from './FeatureCard';
export {Pill} from './Pill';
export {CtaLink, type CtaStyle} from './CtaLink';
export {Rail} from './Rail';
/* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */
export {PlaceCarousel} from './PlaceCarousel';
export {WalkFilterTabs} from './WalkFilterTabs';

View File

@ -7,10 +7,8 @@
import type {IndustryType, SectionItem} from '@o2o/shared';
import type {SectionVariant} from './types';
import {HeroEditorial} from './variants/hero/HeroEditorial';
import {HeroCover} from './variants/hero/HeroCover';
import {HeroSplit} from './variants/hero/HeroSplit';
import {HeroFullBleed} from './variants/hero/HeroFullBleed';
import {HeroTypeOnly} from './variants/hero/HeroTypeOnly';
import {IntroStory} from './variants/intro/IntroStory';
import {IntroCentered} from './variants/intro/IntroCentered';
@ -28,9 +26,9 @@ import {PhotosWithVideos} from './variants/photos/PhotosWithVideos';
import {MapDetailed} from './variants/map/MapDetailed';
import {MapCompact} from './variants/map/MapCompact';
import {LocalFull} from './variants/local/LocalFull';
import {LocalTabs} from './variants/local/LocalTabs';
import {LocalCompact} from './variants/local/LocalCompact';
import {ItineraryTickets} from './variants/itinerary/ItineraryTickets';
import {VideoFrame} from './variants/video/VideoFrame';
import {LocalGuide} from './variants/local/LocalGuide';
import {WeatherSection} from './variants/weather/WeatherSection';
import {FaqAccordion} from './variants/faq/FaqAccordion';
@ -41,8 +39,6 @@ import {RoomsCarousel} from './variants/rooms/RoomsCarousel';
import {RoomsGrid} from './variants/rooms/RoomsGrid';
import {RoomsList} from './variants/rooms/RoomsList';
import {RulesList} from './variants/rules/RulesList';
import {RulesCards} from './variants/rules/RulesCards';
import {BookingCard} from './variants/booking/BookingCard';
import {BookingBanner} from './variants/booking/BookingBanner';
@ -66,23 +62,27 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(dataSpec.ts).
import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip';
import {ChronicleRail} from './variants/chronicle/ChronicleRail';
import {LiteratureShelf} from './variants/literature/LiteratureShelf';
import {PostcardStack} from './variants/postcard/PostcardStack';
import {QuizFlip} from './variants/quiz/QuizFlip';
import {PlannerPodium} from './variants/planner/PlannerPodium';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
/*
* 히어로.
*
* ★ 발행본이 실제로 해석하는 것과 **1:1 이어야 한다**(`site/src/sections/HeroSection.tsx`).
* 예전에는 에디토리얼·풀스크린·타이포 셋을 더 보여줬는데 발행본에 짝이 없어서,
* 무엇을 골라도 발행본은 기본(표지)으로 나왔다 — 고르는 의미가 없는 선택지였다.
* 여기 늘릴 때는 발행본 쪽 분기도 같이 늘린다.
*/
hero: [
{
id: 'hero.editorial',
name: '에디토리얼',
description: '상호 바 + 큰 사진 + 가운데 문구. 분위기부터 보여준다.',
id: 'hero.cover',
name: '표지',
description: '사진 한 장 위, 상호는 좌하단. 사진의 피사체를 글자가 가리지 않는다.',
thumb: 'fullbleed',
Component: HeroEditorial,
Component: HeroCover,
isDefault: true,
},
{
@ -92,20 +92,6 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
thumb: 'split',
Component: HeroSplit,
},
{
id: 'hero.full-bleed',
name: '풀스크린 사진',
description: '사진을 화면 끝까지. 사진 한 장의 힘이 강할 때.',
thumb: 'fullbleed',
Component: HeroFullBleed,
},
{
id: 'hero.type-only',
name: '타이포 중심',
description: '사진 없이 여백과 글자만. 쓸 사진이 없거나 조용한 인상을 원할 때.',
thumb: 'centered',
Component: HeroTypeOnly,
},
],
intro: [
@ -208,29 +194,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
// ★ 전체/탭/요약 세 개를 하나로 통일했다(2026-09-07). 저장된 옛 id(local.tabs·local.compact)는
// resolveVariant 가 기본값으로 떨어뜨리므로 기존 사이트가 깨지지 않는다.
local: [
{
id: 'local.full',
name: '전체',
description: '날씨 + 맛집 + 명소 + 축제를 전부 세로로.',
id: 'local.guide',
name: '가이드',
description: '맛집 · 명소 · 축제를 도보 시간으로 걸러 카드로 넘겨 본다.',
thumb: 'stack',
Component: LocalFull,
Component: LocalGuide,
isDefault: true,
},
{
id: 'local.tabs',
name: '탭 전환',
description: '맛집/명소/축제를 탭으로. 화면 길이를 1/3 로 줄인다.',
thumb: 'accordion',
Component: LocalTabs,
},
{
id: 'local.compact',
name: '요약',
description: '날씨 한 줄과 추천 6곳만. 주인공이 아닐 때.',
thumb: 'compact',
Component: LocalCompact,
},
],
weather: [
@ -294,23 +268,6 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
rules: [
{
id: 'rules.list',
name: '목록',
description: '한 덩어리로 짧게. 가장 눈에 덜 띈다.',
thumb: 'compact',
Component: RulesList,
isDefault: true,
},
{
id: 'rules.cards',
name: '번호 카드',
description: '규정 하나에 칸 하나. 클레임이 잦은 곳에.',
thumb: 'cards',
Component: RulesCards,
},
],
booking: [
{
@ -431,27 +388,7 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
course: [
{
id: 'course.tickets',
name: '승차권',
description: '정거장 하나가 표 한 장. 마지막 표에 완주 도장이 찍힌다.',
thumb: 'carousel',
Component: CourseTickets,
isDefault: true,
},
],
schedule: [
{
id: 'schedule.timetable',
name: '대합실 시간표',
description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.',
thumb: 'carousel',
Component: ScheduleTimetable,
isDefault: true,
},
],
people: [
{
@ -475,16 +412,6 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
literature: [
{
id: 'literature.shelf',
name: '책등 서가',
description: '책등이 가로로 흐르고 고른 책만 세로쓰기로 펼쳐진다.',
thumb: 'carousel',
Component: LiteratureShelf,
isDefault: true,
},
],
postcard: [
{
@ -508,16 +435,6 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
planner: [
{
id: 'planner.podium',
name: '시즌 랭킹',
description: '계절을 고르면 1·2·3위 하루가 뜬다. 시각은 계산해서 채운다.',
thumb: 'timeline',
Component: PlannerPodium,
isDefault: true,
},
],
exhibition: [
{
@ -536,6 +453,28 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
Component: ExhibitionNotice,
},
],
video: [
{
id: 'video.frame',
name: '영상 한 편',
description: '표지를 먼저 보여주고 누르면 재생한다. 세로 영상(쇼츠)은 세로 틀로 나간다.',
thumb: 'fullbleed',
Component: VideoFrame,
isDefault: true,
},
],
itinerary: [
{
id: 'itinerary.tickets',
name: '승차권',
description: '절취선 뚫린 표가 줄줄이. 표 한 장이 정거장 하나이고 마지막에 완주 도장이 찍힌다.',
thumb: 'carousel',
Component: ItineraryTickets,
isDefault: true,
},
],
};
/** 그 업종에서 고를 수 있는 배리에이션. 업종 제한이 걸린 건 걸러낸다. */

View File

@ -6,11 +6,10 @@
* ★ 연도가 없는 항목은 지어내 끼우지 않고 레일 끝으로 민다. 연표에서 틀린 순서는 바로 들킨다.
*/
import {useMemo} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ChronicleItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
@ -20,7 +19,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -73,7 +71,6 @@ function Milestone({item, isLast}: {item: ChronicleItem; isLast: boolean}) {
export function ChronicleRail(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ChronicleItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다.
const items = useMemo(
@ -108,21 +105,16 @@ export function ChronicleRail(props: SectionRenderProps) {
<PasteHint label="시간의 골목" />
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-[11px] opacity-60">
<i
aria-hidden
className="size-2.5 rounded-full"
style={{backgroundColor: ITEM_ACCENT}}
/>
도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
</p>
{items.length > 3 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="연표" />
)}
</div>
<p className="flex items-center gap-1.5 text-[11px] opacity-60">
<i
aria-hidden
className="size-2.5 rounded-full"
style={{backgroundColor: ITEM_ACCENT}}
/>
도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
</p>
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
<Rail label="연표" gap={0}>
{items.map((item, index) => (
<Milestone
key={`${item.year ?? 'x'}-${item.title}-${index}`}
@ -130,7 +122,7 @@ export function ChronicleRail(props: SectionRenderProps) {
isLast={index === items.length - 1}
/>
))}
</div>
</Rail>
</div>
)}
</SectionBody>

View File

@ -1,173 +0,0 @@
/**
* 반나절 산책 — 승차권 캐러셀.
*
* 표 한 장이 정거장 하나다. 절취선·펀치 구멍·완주 도장은 스탬프투어라는 실제 형식을 그대로 옮긴 것.
* ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약.
*/
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type CourseItem, type CourseStop} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_SURFACE,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
function Ticket({
stop,
courseName,
index,
isLast,
}: {
stop: CourseStop;
courseName: string;
index: number;
isLast: boolean;
}) {
const no = String(stop.order ?? index + 1).padStart(2, '0');
return (
<div
className="relative w-[236px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
<div
className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] opacity-75"
style={{borderColor: ITEM_INK}}
>
<span className="truncate">{courseName}</span>
<span className="shrink-0">NO.{no}</span>
</div>
<p className="px-4 pt-4 leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 32, color: ITEM_ACCENT}}>
{no}
</p>
<h4 className="px-4 pt-1.5 text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{stop.name}
</h4>
{stop.note && (
<p className="px-4 pt-2 text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{stop.note}
</p>
)}
<div
className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] opacity-60"
style={{borderColor: ITEM_BORDER}}
>
<span>{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'}</span>
{stop.searchQuery && <span className="truncate">지도 검색 · {stop.searchQuery}</span>}
</div>
{/* 펀치 구멍 — 표를 표로 만드는 자리 */}
<i
className="absolute -left-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_INK}}
/>
<i
className="absolute -right-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_INK}}
/>
{isLast && (
<span
className="absolute bottom-3 right-3 grid size-14 -rotate-12 place-items-center rounded-full border-2 text-center text-[11px] leading-tight opacity-75"
style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
완주
<br />
도장
</span>
)}
</div>
);
}
function CourseRow({course}: {course: CourseItem}) {
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
const stops = course.stops ?? [];
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{course.name}
</h3>
<span className="text-[11px] opacity-60">
{[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
{stops.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={course.name} />
)}
</div>
{stops.length === 0 ? (
<p className="border border-dashed px-4 py-5 text-center text-[11px] opacity-60" style={{borderColor: ITEM_BORDER}}>
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p>
) : (
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{stops.map((stop, index) => (
<Ticket
key={`${stop.name}-${index}`}
stop={stop}
courseName={course.name}
index={index}
isLast={index === stops.length - 1}
/>
))}
</div>
)}
<SourceLine source={course.source} verified={course.verified} />
</div>
);
}
export function CourseTickets(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<CourseItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="반나절 산책" />
) : (
<div className="space-y-8">
{parsed.items.map((course, index) => (
<CourseRow key={`${course.name}-${index}`} course={course} />
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,84 @@
/**
* 히어로 · 표지 — 사진 한 장 위, 글은 좌하단.
*
* ★ 왜 가운데가 아닌가
* 사진이 주인공인 화면에서 글자를 한가운데 얹으면 피사체를 정확히 가린다. 숙소 사진은
* 가운데에 방이나 사람이 오는데, 상호가 그 위에 앉으면 둘 다 못 읽는다. 아래로 내리고
* 그쪽만 어둡게 덮으면 사진은 사진대로 남고 글자는 글자대로 읽힌다.
*
* ★ 버튼을 세우지 않는다
* 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다. 예약 버튼은 아래 예약 섹션이 맡고,
* 여기서는 다음 섹션으로 눈을 내려보내기만 한다.
*
* ★ 색을 직접 쓰지 않는다
* `bg-stone-900` 같은 고정색을 두면 '옛 항구'(갱지)를 골라도 첫 화면만 검게 남는다.
* 어두운 면은 `--tpl-inverse`, 그 위 글자는 `--tpl-bg` 다 — 팔레트가 바뀌면 같이 바뀐다.
*/
import {ChevronDown, MapPin} from 'lucide-react';
import {SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {primaryPhoto} from '../common';
/** 사진 위 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */
const SCRIM =
'linear-gradient(to top,' +
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' +
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%,' +
' transparent 66%)';
export function HeroCover(props: SectionRenderProps) {
const {section, isSelected, onSelect, storeName, location, photos} = props;
// 사진이 없으면 <img> 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다.
const cover = primaryPhoto(photos);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div
className="relative flex w-full select-none items-end overflow-hidden"
style={{
backgroundColor: 'var(--tpl-inverse, #1c1917)',
color: 'var(--tpl-bg, #ffffff)',
minHeight: 'clamp(24rem, 62vh, 36rem)',
}}
>
{cover?.url && (
<div className="absolute inset-0 z-0">
<img src={cover.url} alt={storeName} className="size-full object-cover object-center" />
<div className="absolute inset-0" style={{background: SCRIM}} />
</div>
)}
<div className="shell relative z-10 pb-10 pt-24 sm:pb-12">
{location && (
<p className="mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80">
<MapPin className="size-3.5" aria-hidden />
{location}
</p>
)}
{/* ★ tpl-title — 굵기를 템플릿이 정한다. 간판체(Gugi)는 굵기가 한 벌뿐이라
700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. */}
<h1
className="tpl-title leading-[1.15]"
style={{fontSize: 'var(--fs-display)'}}
>
{storeName}
</h1>
{/*
* ★ 여기에 `section.description` 을 쓰지 않는다.
* 그 칸은 에디터의 섹션 설명("상단 메인 비주얼과 대표 문구")이라, 캔버스에 그리면
* 사장님 화면에 우리 UI 안내문이 자기 소개문처럼 박힌다. 실제로 그렇게 나갔다.
* 대표 문구는 수집·생성된 값이 생기기 전까지 **아무것도 그리지 않는다.**
*/}
<p className="mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] opacity-80">
객실 보기
<ChevronDown className="size-4" aria-hidden />
</p>
</div>
</div>
</SectionFrame>
);
}

View File

@ -1,66 +0,0 @@
/**
* 히어로 · 에디토리얼 — 상단 브랜드 바 + 큰 사진 + 가운데 세리프 카피.
* 감성 숙소/공방처럼 "분위기부터 보여주고 싶은" 곳의 기본값.
*/
import {Calendar} from 'lucide-react';
import {SectionFrame} from '../../primitives';
import {CtaLink} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref, primaryPhoto} from '../common';
export function HeroEditorial(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, location, template, photos} = props;
const cover = primaryPhoto(photos);
// 사진이 없으면 <img> 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다.
const coverUrl = cover?.url;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div
className="flex w-full select-none flex-col"
style={{backgroundColor: template.colors.bg}}
>
<div className="mx-auto flex w-full max-w-[1120px] items-center justify-between border-b border-stone-200/60 bg-white/90 px-4 py-4 backdrop-blur-xs sm:px-6">
<span
className="serif-title text-base font-bold uppercase tracking-widest text-stone-800 sm:text-lg"
style={{color: template.colors.text}}
>
{storeName}
</span>
</div>
<div className="relative flex h-[50vh] w-full items-center justify-center overflow-hidden bg-stone-900 sm:h-[65vh]">
{coverUrl && (
<img
src={coverUrl}
alt={storeName}
className="absolute inset-0 size-full object-cover opacity-75"
/>
)}
<div className="absolute inset-0 bg-black/45" />
<div className="relative z-10 mx-auto w-full max-w-xl space-y-4 px-6 text-center text-white">
<h1 className="serif-title text-2xl font-bold leading-tight tracking-tight drop-shadow-xs sm:text-4xl md:text-5xl">
{storeName}
</h1>
<p className="text-xs font-light tracking-wide text-stone-200 sm:text-sm md:text-base">
{section.description || location}
</p>
<div className="flex items-center justify-center gap-2 pt-2">
<CtaLink
href={bookingHref(industryId, storeName)}
variant="pill"
colors={template.colors}
>
<Calendar className="size-3.5" />
<span>예약 안내</span>
</CtaLink>
</div>
</div>
</div>
</div>
</SectionFrame>
);
}

View File

@ -1,54 +0,0 @@
/**
* 히어로 · 풀블리드 — 화면을 꽉 채운 사진 위에 왼쪽 아래로 붙는 큰 타이포.
* 사진 한 장의 힘이 강한 곳(뷰 좋은 스테이, 플레이팅이 강한 다이닝)에 맞는다.
*/
import {Calendar, MapPin} from 'lucide-react';
import {CtaLink, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref, primaryPhoto} from '../common';
export function HeroFullBleed(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, location, template, photos} = props;
const cover = primaryPhoto(photos);
// 사진이 없으면 <img> 를 아예 렌더하지 않고 어두운 배경만 남긴다.
const coverUrl = cover?.url;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div className="relative h-[62vh] min-h-[380px] w-full select-none overflow-hidden bg-stone-900 sm:h-[78vh]">
{coverUrl && (
<img
src={coverUrl}
alt={storeName}
className="absolute inset-0 size-full object-cover"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/10" />
<div className="absolute inset-x-0 bottom-0 px-6 pb-10 sm:px-10 sm:pb-14">
<div className="mx-auto max-w-[1120px] space-y-3 text-white">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-[11px] font-medium backdrop-blur-xs">
<MapPin className="size-3" />
{section.description || location}
</span>
<h1 className="serif-title max-w-2xl text-3xl font-bold leading-[1.15] tracking-tight drop-shadow-sm sm:text-5xl md:text-6xl">
{storeName}
</h1>
<div className="flex flex-wrap items-center gap-2 pt-2">
<CtaLink
href={bookingHref(industryId, storeName)}
variant="brand"
colors={template.colors}
>
<Calendar className="size-3.5" />
<span>지금 예약하기</span>
</CtaLink>
</div>
</div>
</div>
</div>
</SectionFrame>
);
}

View File

@ -1,47 +0,0 @@
/**
* 히어로 · 타이포 중심 — 사진 없이 여백과 글자만.
* 쓸 만한 사진이 아직 없거나, 조용한 인상을 주고 싶을 때 고른다.
*/
import {ArrowUpRight} from 'lucide-react';
import {CtaLink, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref, industryConfig} from '../common';
export function HeroTypeOnly(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, location, template} = props;
const config = industryConfig(industryId);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div
className="px-6 py-20 sm:px-10 sm:py-28"
style={{backgroundColor: template.colors.bg}}
>
<div className="mx-auto max-w-2xl space-y-6 text-center">
<span
className="text-[11px] font-semibold uppercase tracking-[0.35em]"
style={{color: template.colors.accent}}
>
{section.description || `${config.name} · ${location}`}
</span>
<h1
className="serif-title text-3xl font-bold leading-[1.2] tracking-tight sm:text-5xl"
style={{color: template.colors.text}}
>
{storeName}
</h1>
<div className="mx-auto h-px w-12" style={{backgroundColor: template.colors.primary}} />
<div className="flex justify-center pt-2">
<CtaLink href={bookingHref(industryId, storeName)} variant="outline">
<span>{storeName} 둘러보기</span>
<ArrowUpRight className="size-3.5" />
</CtaLink>
</div>
</div>
</div>
</SectionFrame>
);
}

View File

@ -8,7 +8,6 @@
* ★ 질감(도넛판 홈·톱니·펀치 구멍)은 남긴다 — 그건 색이 아니라 물건의 생김새다.
* 질감의 색도 토큰을 따라가게 `items.css` 에서 var() 로 받는다.
*/
import {useCallback, useRef} from 'react';
import {ChevronLeft, ChevronRight, MousePointerClick} from 'lucide-react';
import {cn} from '@/lib/utils';
import type {DataSource, DataVerified} from '@o2o/shared';
@ -102,17 +101,12 @@ export function SourceLine({
);
}
/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */
export function useCarousel<T extends HTMLElement>() {
const ref = useRef<T>(null);
const scrollBy = useCallback((dir: -1 | 1) => {
const box = ref.current;
if (!box) return;
box.scrollBy({left: box.clientWidth * 0.8 * dir, behavior: 'smooth'});
}, []);
return {ref, scrollBy};
}
/**
* 앞뒤 버튼 한 쌍.
*
* ★ 가로 슬라이더에는 쓰지 않는다 — 그건 `Rail`(embla)이 자기 화살표를 갖는다.
* 여기 남은 쓰임은 **스크롤이 아닌 이동**이다(일력: 장을 넘겨 오늘 자리를 바꾼다).
*/
export function CarouselNav({
onPrev,
onNext,

View File

@ -91,12 +91,30 @@
transform: rotateY(180deg);
}
/*
* 가로 슬라이더의 창(viewport).
*
* 스크립트가 붙기 전에는 그냥 가로 스크롤 상자다. embla 가 붙으면 `data-slider="on"` 이
* 걸리고 그때부터 드래그가 스크롤을 대신한다 — 둘을 같이 켜면 관성이 겹쳐 튄다.
*/
.w4-scroll {
overflow-x: auto;
scrollbar-width: none;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.w4-scroll::-webkit-scrollbar {
display: none;
}
.w4-scroll[data-slider='on'] {
overflow-x: hidden;
scroll-snap-type: none;
}
/* 문장을 잡으면 카드가 안 끌린다 — 끄는 동안만 선택을 끈다. */
.w4-scroll[data-dragging='true'] {
cursor: grabbing;
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.w4-spin {

View File

@ -0,0 +1,179 @@
/**
* 추천 일정 — 캔버스. 승차권 한 장이 정거장 하나다.
*
* ★ 셋을 합친 자리다(반나절 산책 · 여행 스케줄 · 계절별 추천 하루). 축이 계절이 아니라 **기간**이다.
* ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
* 출발을 당기면 하루가 통째로 밀린다.
*/
import {
currentSeasons,
inSeason,
itineraryDays,
itineraryDurations,
parseSectionData,
type ItineraryItem,
type PlannedStop,
} from '@o2o/shared';
import {SectionBody, SectionFrame, Rail} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
export function ItineraryTickets(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ItineraryItem>(section.type, section.data);
const live = currentSeasons();
const durations = itineraryDurations(parsed.items);
const groups: (string | undefined)[] =
durations.length > 0
? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
: [undefined];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="추천 일정" />
) : (
<div className="space-y-7">
{groups.map((duration) => {
const items = parsed.items.filter((item) =>
duration === undefined
? !item.duration?.trim()
: item.duration?.trim() === duration,
);
if (items.length === 0) return null;
return (
<div key={duration ?? 'etc'} className="space-y-4">
{duration && (
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{duration}
</h3>
)}
{items.map((item, index) => (
<div key={`${item.name}-${index}`} className="space-y-2.5">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>
{item.name}
</h4>
<span className="text-[11px] opacity-60">
{[item.audience, item.season].filter(Boolean).join(' · ')}
</span>
{/* 계절을 적으면 손님 화면에서는 그 계절에만 나간다 — 사장님이 알아야 한다. */}
{item.season && (
<span className="text-[11px] opacity-60">
{inSeason(item.season, live)
? '· 지금 나갑니다'
: `· 손님 화면에는 ${item.season} 에만 나갑니다`}
</span>
)}
</div>
{item.why && (
<p className="max-w-[52ch] text-[13px] leading-relaxed opacity-80">
{item.why}
</p>
)}
{itineraryDays(item).map((entry, dayIndex) => (
<div key={`${entry.label}-${dayIndex}`} className="space-y-1.5">
{entry.label && (
<p className="text-[11px] font-bold" style={{color: ITEM_ACCENT}}>
{entry.label}
<span className="ml-2 font-normal opacity-60">
{entry.day.from}–{entry.day.to}
</span>
</p>
)}
<Rail label={`${item.name} ${entry.label || '정거장'}`}>
{entry.day.stops.map((planned, stopIndex) => (
<Ticket
key={`${planned.stop.name}-${stopIndex}`}
planned={planned}
no={stopIndex + 1}
isLast={stopIndex === entry.day.stops.length - 1}
/>
))}
</Rail>
{entry.day.dropped > 0 && (
<p className="text-[11px] opacity-60">
밤 9시를 넘기는 {entry.day.dropped}곳은 화면에서 뺐습니다.
</p>
)}
</div>
))}
<SourceLine source={item.source} verified={item.verified} />
</div>
))}
</div>
);
})}
</div>
)}
</SectionBody>
</SectionFrame>
);
}
/** 승차권 한 장. 마지막 장에는 완주 도장(아티팩트의 스탬프투어 형식). */
function Ticket({planned, no, isLast}: {planned: PlannedStop; no: number; isLast: boolean}) {
const {stop, time, until, move} = planned;
return (
<article
className="w4-perf relative w-[214px] shrink-0 border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-baseline justify-between gap-2 border-b border-dashed px-3.5 py-2"
style={{borderColor: ITEM_BORDER}}
>
<span className="text-[10px] tracking-[0.2em] opacity-60">
NO.{String(no).padStart(2, '0')}
</span>
<span className="text-[13px] font-bold tabular-nums" style={{fontFamily: ITEM_HEADING}}>
{time}
<span className="opacity-55">–{until}</span>
</span>
</div>
<div className="space-y-1 px-3.5 pb-3.5 pt-2.5">
<h5 className="text-sm font-bold" style={{fontFamily: ITEM_HEADING}}>
{stop.name}
</h5>
{stop.note && <p className="text-[12px] leading-relaxed opacity-80">{stop.note}</p>}
{move > 0 && <p className="text-[10px] opacity-55">↓ 앞 칸에서 {move}분 이동</p>}
</div>
{isLast && (
<span
className="absolute bottom-2.5 right-2.5 grid size-10 rotate-[-12deg] place-items-center rounded-full border-2 text-[10px] font-bold"
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
aria-hidden
>
완주
</span>
)}
</article>
);
}

View File

@ -1,149 +0,0 @@
/**
* 문학 서가 — 책등 캐러셀 + 세로쓰기 펼침면.
*
* 책등이 가로로 흐르고, 하나를 고르면 그 책만 앞으로 나와 펼쳐진다.
* 펼친 면의 제목은 **세로쓰기**다 — 원문을 못 싣는 대신(DB_Guide: 원문 전재 금지)
* 활자의 결로 문학을 느끼게 하는 자리다.
*/
import {useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type LiteratureItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INVERSE_INK,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
/** 책등 색을 안 줬을 때. 템플릿 강조색으로 떨어뜨린다 — 색을 박으면 팔레트를 바꿔도 서가만 남는다. */
const FALLBACK_SPINE = ITEM_ACCENT;
export function LiteratureShelf(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<LiteratureItem>(section.type, section.data);
const [opened, setOpened] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 책으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[opened] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<PasteHint label="문학 서가" />
) : (
<div className="space-y-5">
{/* 서가 — 아래 선반이 있어야 책이 '꽂혀 있다'로 읽힌다 */}
<div className="space-y-2">
<div
ref={ref}
className="w4-scroll flex items-end gap-1.5 overflow-x-auto border-b-4 px-2 pt-4"
// 선반 널. 글자색을 섞어 만들면 어떤 팔레트에서도 '받치는 판'으로 읽힌다.
style={{borderColor: 'color-mix(in oklab, currentColor 45%, transparent)'}}
>
{parsed.items.map((book, index) => (
<button
key={`${book.workTitle}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setOpened(index);
}}
aria-current={index === opened}
className="h-[184px] w-[40px] shrink-0 snap-center px-1 py-3 text-center transition-all"
style={{
backgroundColor: book.spineColor || FALLBACK_SPINE,
// 책등 색은 사장님이 정하므로 그 위 글자는 밝은 면 색으로 고정한다.
color: ITEM_INVERSE_INK,
// 고른 책만 한 칸 튀어나온다 — 뽑아 든 자리
transform: index === opened ? 'translateY(-12px)' : undefined,
boxShadow: index === opened ? '0 6px 14px rgba(27,26,21,.35)' : undefined,
}}
>
<span
className="mx-auto block h-full overflow-hidden text-[13px] leading-tight"
style={{fontFamily: ITEM_BODY, writingMode: 'vertical-rl'}}
>
{book.workTitle}
{book.author ? ` · ${book.author}` : ''}
</span>
</button>
))}
</div>
{parsed.items.length > 6 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="책" />
)}
</div>
{/* 펼친 면 */}
<div
className="w4-paper grid gap-5 border p-5 sm:p-6 md:grid-cols-[auto_minmax(0,1fr)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<p
className="mx-auto h-fit w-fit max-h-[240px] overflow-hidden border-r pr-4 leading-tight md:mx-0"
style={{
fontFamily: ITEM_HEADING,
fontSize: 30,
writingMode: 'vertical-rl',
borderColor: ITEM_BORDER,
}}
>
{current.workTitle}
</p>
<div className="min-w-0 space-y-3">
<p className="text-xs opacity-60">
{[current.author, current.year, current.genre].filter(Boolean).join(' · ')}
</p>
{current.background && (
<p
className="text-sm leading-relaxed opacity-85"
style={{fontFamily: ITEM_BODY}}
>
{current.background}
</p>
)}
{current.whyHere && (
<p
className="border-l-2 pl-3 text-sm leading-relaxed opacity-75"
style={{fontFamily: ITEM_BODY, borderColor: ITEM_BORDER}}
>
{current.whyHere}
</p>
)}
<span className="inline-block border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}>
◎ 원문 대신 배경 — 작품 문장은 싣지 않습니다
</span>
<SourceLine source={current.source} verified={current.verified} />
</div>
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,27 +0,0 @@
import {useEffect, useState} from 'react';
/** "실시간 업데이트: 오후 4:41:56" 표시. 1초마다 갱신된다. */
export function LiveClock() {
const [clock, setClock] = useState('');
useEffect(() => {
const update = () => {
const now = new Date();
const period = now.getHours() >= 12 ? '오후' : '오전';
const hours = now.getHours() % 12 || 12;
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
setClock(`${period} ${hours}:${minutes}:${seconds}`);
};
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, []);
return (
<span className="flex items-center gap-1.5 rounded-full border border-stone-200/80 bg-white px-2.5 py-1 text-[11px] text-stone-500 shadow-2xs">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-500" />
<span>실시간 업데이트: {clock}</span>
</span>
);
}

View File

@ -0,0 +1,61 @@
/**
* 지역 가이드 · 카테고리 한 묶음 — 제목(아이콘) → 도보 시간 필터 → 카드 캐러셀.
*
* 맛집·명소·축제가 전부 이 모양이다. 필터 상태는 묶음마다 따로 든다 —
* 맛집을 "5분 이내"로 걸렀는데 명소까지 줄어들면 사장님이 명소가 사라진 줄 안다.
*/
import {useState, type ReactNode} from 'react';
import type {LucideIcon} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {EmptyStateNotice, PlaceCarousel, WalkFilterTabs} from '../../primitives';
import type {GuideCard} from './types';
import {matchesWalkFilter, type WalkFilterKey} from './walking';
export function LocalCategorySection({
icon: Icon,
title,
cards,
emptyText,
colors,
renderLeading,
}: {
icon: LucideIcon;
title: string;
cards: GuideCard[];
emptyText: string;
colors: TemplateItem['colors'];
renderLeading?: (card: GuideCard) => ReactNode;
}) {
const [filter, setFilter] = useState<WalkFilterKey>('all');
const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters));
return (
<section className="space-y-4">
<h3
className="flex items-center gap-2 text-sm font-bold text-stone-900"
style={{color: colors.text}}
>
<Icon className="size-4" style={{color: colors.primary}} />
<span>{title}</span>
</h3>
{cards.length === 0 ? (
<EmptyStateNotice>{emptyText}</EmptyStateNotice>
) : (
<>
<WalkFilterTabs
distances={cards.map((c) => c.distanceMeters)}
value={filter}
onChange={setFilter}
colors={colors}
/>
{visible.length === 0 ? (
<EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice>
) : (
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} />
)}
</>
)}
</section>
);
}

View File

@ -1,50 +0,0 @@
/**
* 지역 가이드 · 요약 — 날씨 한 줄과 추천 6곳만.
* 지역 정보가 주인공이 아닌 사이트에서 "있긴 하다" 정도로 둘 때.
*/
import {
EmptyStateNotice,
ListCard,
PlaceRow,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
export function LocalCompact(props: SectionRenderProps) {
const {section, isSelected, onSelect, location, template} = props;
// 맛집·명소를 섞어 딱 6개만. 목록이 길어지면 요약이 아니게 된다.
// ★ 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약(SectionRenderProps)에 없다.
// 시연용 목록(제주 애월)으로 자리를 메우지 않는다 — 남의 동네 맛집이 사장님 사이트에 붙는다.
const picks: {name: string; meta: string; description: string; q: string}[] = [];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="narrow">
<SectionHeading variant="minimal" title="주변 안내" colors={template.colors} />
{picks.length === 0 ? (
<EmptyStateNotice>주변 추천은 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{picks.map((p) => (
<PlaceRow
key={p.name}
name={p.name}
meta={p.meta}
description={p.description}
href={naverSearch(p.q)}
actionLabel="보기"
colors={template.colors}
/>
))}
</ListCard>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,163 +0,0 @@
/**
* 지역 가이드 · 전체 — 날씨 카드 + 맛집 + 명소 + 월별 축제를 세로로 전부.
* "여기 오면 근처에 뭐가 있나"를 한 화면에서 다 보여주고 싶을 때.
*/
import {Calendar, MapPin, Sparkles, Utensils} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {
EmptyStateNotice,
ListCard,
PlaceRow,
Pill,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {LiveClock} from './LiveClock';
import type {FestivalItem, NearbyPlace} from './types';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
function GroupHeading({
icon: Icon,
title,
note,
colors,
}: {
icon: typeof Utensils;
title: string;
note: string;
colors: TemplateItem['colors'];
}) {
return (
<div className="flex items-center justify-between">
<h3
className="serif-title flex items-center gap-2 text-base font-bold text-stone-900 sm:text-lg"
style={{color: colors.text}}
>
<Icon className="size-4 text-stone-600" style={{color: colors.primary}} />
<span>{title}</span>
</h3>
{/* 오른쪽 출처 문구는 부가 정보라 중립 회색 그대로 둔다. */}
<span className="text-[11px] text-stone-400">{note}</span>
</div>
);
}
export function LocalFull(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, location, template} = props;
const isStay = industryId === 'stay';
const locName = location.split(' ')[0] || '주변';
// ★ 맛집·명소·축제는 전부 제주 애월 시연용 목록이다 — 실사업장은 빈 목록으로 떨어진다.
// (지역 큐레이션을 읽어올 백엔드 창구가 아직 없어 당분간 계속 빈다.)
// 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약에 없다 — 시연용 목록을 깔지 않는다.
const foods: NearbyPlace[] = [];
const spots: NearbyPlace[] = [];
const festivals: FestivalItem[] = [];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody className="space-y-8">
<SectionHeading
variant="eyebrow"
eyebrow="AI Local Guide"
title={`AI ${locName} 실시간 가이드`}
subtitle="실시간 날씨와 인근 미식·명소·축제를 AI가 큐레이션합니다"
trailing={<LiveClock />}
colors={template.colors}
/>
<div className="space-y-3.5">
<GroupHeading
icon={Utensils}
title="숙소 인근 엄선 맛집 & 카페"
note="호스트 & 네이버 플레이스 연동"
colors={template.colors}
/>
{foods.length === 0 ? (
<EmptyStateNotice>주변 맛집·카페 추천은 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{foods.map((place) => (
<PlaceRow
key={place.name}
name={place.name}
meta={place.distance}
description={place.description}
href={naverSearch(place.searchQuery)}
colors={template.colors}
/>
))}
</ListCard>
)}
</div>
<div className="space-y-3.5">
<GroupHeading
icon={MapPin}
title="주변 힐링 명소 & 해변"
note="차량 5~20분 거리"
colors={template.colors}
/>
{spots.length === 0 ? (
<EmptyStateNotice>주변 명소 추천은 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{spots.map((spot) => (
<PlaceRow
key={spot.name}
name={spot.name}
meta={spot.duration}
description={spot.description}
href={naverSearch(spot.searchQuery)}
actionLabel="상세정보"
colors={template.colors}
/>
))}
</ListCard>
)}
</div>
{isStay && (
<div className="space-y-3.5">
<GroupHeading
icon={Calendar}
title="월별 축제 & 문화 행사"
note="사계절 캘린더"
colors={template.colors}
/>
{festivals.length === 0 ? (
<EmptyStateNotice>월별 축제 안내는 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{festivals.map((fest) => (
<PlaceRow
key={fest.name}
name={fest.name}
description={fest.description || fest.period}
href={naverSearch(fest.searchQuery)}
actionLabel="검색"
leading={
<>
<Pill tone="accent">{fest.month}</Pill>
{fest.isFeatured && (
<Pill tone="good">
<Sparkles className="size-2.5" />
대표축제
</Pill>
)}
</>
}
colors={template.colors}
/>
))}
</ListCard>
)}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,88 @@
/**
* 지역 가이드 — 맛집 · 명소 · 축제를 카테고리별 "도보 시간 필터 + 카드 캐러셀"로 세로 나열.
*
* ★ 2026-09-07 결정으로 전체/탭/요약 세 배리에이션을 이 하나로 통일했다.
* 데이터가 이미 카테고리별로 나뉘어 있어 "탭으로 갈아끼우기"라는 구분이 캐러셀 구조에서는
* 의미가 없어졌고, 요약(6곳)은 필터가 그 역할을 대신한다.
* ★ 값은 서버(local.place_contents)가 소유한다 — 업장 좌표 반경으로 받은 것만 그린다.
* 시연용 목록으로 자리를 메우지 않는다(남의 동네 맛집이 사장님 사이트에 붙는다).
*/
import {Calendar, MapPin, Utensils} from 'lucide-react';
import {useLocalGuide} from '@/hooks/useLocalGuide';
import {Pill, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import type {FestivalCard, GuideCard} from './types';
import {LocalCategorySection} from './LocalCategorySection';
import {WALK_DISCLAIMER} from './walking';
/** "2026년 9월 3일 갱신" — 서버 수집 시각(ISO)에서. 없으면 비운다(지어내지 않는다). */
function syncedLabel(iso?: string): string | undefined {
if (!iso) return undefined;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일 갱신`;
}
/** "전북특별자치도 군산시 …" → "군산시". 시군구 토큰이 없으면 첫 토큰. */
function regionLabel(location: string): string {
const tokens = location.split(' ').filter(Boolean);
return tokens[1] ?? tokens[0] ?? '주변';
}
export function LocalGuide(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, location, template} = props;
const {foods, spots, festivals, syncedAt} = useLocalGuide();
const colors = template.colors;
const isStay = industryId === 'stay';
const synced = syncedLabel(syncedAt);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide" className="space-y-10">
<SectionHeading
title={section.name || '주변 안내'}
subtitle={`${regionLabel(location)} 지역의 맛집 · 명소 안내입니다.`}
trailing={synced && <span className="text-xs text-stone-400">{synced}</span>}
colors={colors}
/>
<LocalCategorySection
icon={Utensils}
title="주변 맛집"
cards={foods}
emptyText="주변 맛집·카페 추천은 아직 준비 중입니다."
colors={colors}
/>
<LocalCategorySection
icon={MapPin}
title="주변 명소"
cards={spots}
emptyText="주변 명소 추천은 아직 준비 중입니다."
colors={colors}
/>
{isStay && (
<LocalCategorySection
icon={Calendar}
title="주변 축제 & 문화 행사"
cards={festivals}
emptyText="주변 축제 안내는 아직 준비 중입니다."
colors={colors}
renderLeading={(card: GuideCard) => {
const fest = card as FestivalCard;
return (
<div className="flex flex-wrap items-center gap-1.5">
{fest.month && <Pill tone="accent">{fest.month}</Pill>}
{fest.period && <Pill mono>{fest.period}</Pill>}
</div>
);
}}
/>
)}
<p className="text-[11px] leading-relaxed text-stone-400">{WALK_DISCLAIMER}</p>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,124 +0,0 @@
/**
* 지역 가이드 · 탭 — 맛집/명소/축제를 탭으로 갈아 끼운다.
* 목록이 길어 스크롤이 부담스러울 때 화면 길이를 1/3 로 줄인다.
*/
import {useState} from 'react';
import {Calendar, MapPin, Utensils} from 'lucide-react';
import {cn} from '@/lib/utils';
import {
EmptyStateNotice,
ListCard,
Pill,
PlaceRow,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import type {FestivalItem, NearbyPlace} from './types';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
type TabKey = 'food' | 'spots' | 'festivals';
const TABS: {key: TabKey; label: string; icon: typeof Utensils}[] = [
{key: 'food', label: '맛집 · 카페', icon: Utensils},
{key: 'spots', label: '명소', icon: MapPin},
{key: 'festivals', label: '축제', icon: Calendar},
];
export function LocalTabs(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, location, template} = props;
const [tab, setTab] = useState<TabKey>('food');
const isStay = industryId === 'stay';
const tabs = isStay ? TABS : TABS.filter((t) => t.key !== 'festivals');
const locName = location.split(' ')[0] || '주변';
// ★ 세 탭 모두 제주 애월 시연용 목록이다 — 실사업장은 빈 목록으로 떨어진다.
// 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약에 없다 — 시연용 목록을 깔지 않는다.
const foods: NearbyPlace[] = [];
const spots: NearbyPlace[] = [];
const festivals: FestivalItem[] = [];
// 탭을 눌렀는데 아무것도 없으면 섹션이 사라진 것처럼 보인다 — 자리는 지키고 상태만 알린다.
const isEmpty = (tab === 'food' ? foods : tab === 'spots' ? spots : festivals).length === 0;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody>
<SectionHeading
variant="eyebrow"
eyebrow="AI Local Guide"
title={`AI ${locName} 실시간 가이드`}
subtitle="탭을 눌러 원하는 정보만 보세요"
colors={template.colors}
/>
<div className="flex items-center gap-1 rounded-xl border border-stone-200/80 bg-white p-1">
{tabs.map(({key, label, icon: Icon}) => (
<button
key={key}
type="button"
onClick={(e) => {
e.stopPropagation();
setTab(key);
}}
aria-pressed={tab === key}
style={tab === key ? {backgroundColor: template.colors.primary} : undefined}
className={cn(
'flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors',
tab === key ? 'text-white' : 'text-stone-500 hover:bg-stone-100',
)}
>
<Icon className="size-3.5" />
<span>{label}</span>
</button>
))}
</div>
{isEmpty ? (
<EmptyStateNotice>주변 정보는 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{tab === 'food' &&
foods.map((place) => (
<PlaceRow
key={place.name}
name={place.name}
meta={place.distance}
description={place.description}
href={naverSearch(place.searchQuery)}
colors={template.colors}
/>
))}
{tab === 'spots' &&
spots.map((spot) => (
<PlaceRow
key={spot.name}
name={spot.name}
meta={spot.duration}
description={spot.description}
href={naverSearch(spot.searchQuery)}
actionLabel="상세정보"
colors={template.colors}
/>
))}
{tab === 'festivals' &&
festivals.map((fest) => (
<PlaceRow
key={fest.name}
name={fest.name}
description={fest.description || fest.period}
href={naverSearch(fest.searchQuery)}
actionLabel="검색"
leading={<Pill tone="accent">{fest.month}</Pill>}
colors={template.colors}
/>
))}
</ListCard>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,32 +1,26 @@
/**
* 지역 정보 항목의 모양.
* 지역 가이드 카드 한 장의 모양(맛집·명소·축제 공통).
*
* ★ 원래 이 타입들은 시연용 데이터 파일(data/stayData.ts)이 소유했다. 그 파일을 지우면서
* 실제로 쓰는 쪽인 여기로 옮겼다 — 타입이 데이터 시드에 매여 있을 이유가 없다.
*
* ★ 값은 서버(local.local_contents)가 소유한다. 지금은 캔버스 계약(SectionRenderProps)에
* 지역 정보가 없어서 배리에이션들이 빈 목록을 그린다. 흘려보낼 창구가 생기면
* 이 모양에 맞춰 매핑하면 된다.
* ★ 값은 서버(local.place_contents)가 소유한다. hooks/useLocalGuide 가 GET /v1/local/guide
* 응답(발행 payload 와 같은 모양)을 이 타입으로 매핑한다.
* ★ 도보 시간 배지·필터는 distanceMeters 로 계산한다(walking.ts::walkMinutes, 분속 80m).
* distanceMeters 가 없으면(지역 캐시에서 온 수기 항목 등) 배지·필터 대상에서 빠진다 —
* 업장 기준 거리를 모르는 값으로 "도보 N분"을 지어내지 않는다.
*/
export interface NearbyPlace {
export interface GuideCard {
name: string;
category: string;
/** 거리 표기("차로 5분", "1.2km") — 원문 그대로 옮긴다. */
distance: string;
duration: string;
description: string;
/** 상세 페이지 대신 검색으로 보낸다 — 없는 주소를 지어내지 않기 위해서다. */
searchQuery: string;
tag: string;
imageUrl?: string;
distanceMeters?: number;
/** 화면 배지에 그대로 쓰는 거리 문자열("850m"/"1.2km"). */
distanceText?: string;
}
export interface FestivalItem {
/** 축제는 기간·월 배지가 더 붙는다. */
export interface FestivalCard extends GuideCard {
month: string;
name: string;
period: string;
location: string;
description: string;
isFeatured?: boolean;
officialUrl?: string;
searchQuery: string;
}

View File

@ -0,0 +1,36 @@
/**
* 도보 시간 환산 — 직선거리(m) → 분.
*
* ★ 분속 80m 로 나눈다(보통 걸음 4.8km/h). 스크린샷 문구와 같은 기준이며 화면 하단에도 그대로 적는다:
* "도보 시간은 숙소에서 잰 직선거리를 분속 80m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다."
* ★ 실측 검증(2026-09-07): 135m→2분 · 297m→4분 · 305m→4분 · 566m→7분 · 13m→1분 — 반올림 + 최소 1분.
*/
export const WALK_METERS_PER_MINUTE = 80;
export const WALK_DISCLAIMER =
`도보 시간은 숙소에서 잰 직선거리를 분속 ${WALK_METERS_PER_MINUTE}m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다.`;
export function walkMinutes(meters: number): number {
return Math.max(1, Math.round(meters / WALK_METERS_PER_MINUTE));
}
/**
* 도보 시간 필터. ★ 구간은 배타적이 아니라 **누적**이다 — "10분 이내"는 "5분 이내"를 포함한다.
* (스크린샷 수치 8·19·5 → 19+5=24=전체 로 확인. "5분 이내"는 "10분 이내"의 부분집합.)
*/
export type WalkFilterKey = 'all' | 'within5' | 'within10' | 'over10';
export const WALK_FILTERS: {key: WalkFilterKey; label: string; test: (minutes: number) => boolean}[] = [
{key: 'all', label: '전체', test: () => true},
{key: 'within5', label: '걸어서 5분 이내', test: (m) => m <= 5},
{key: 'within10', label: '걸어서 10분 이내', test: (m) => m <= 10},
{key: 'over10', label: '걸어서 10분 이상', test: (m) => m > 10},
];
/** 거리를 모르는 항목은 '전체'에만 들어간다 — 모르는 값으로 구간을 정하지 않는다. */
export function matchesWalkFilter(key: WalkFilterKey, meters: number | undefined): boolean {
if (key === 'all') return true;
if (meters === undefined) return false;
const filter = WALK_FILTERS.find((f) => f.key === key);
return filter ? filter.test(walkMinutes(meters)) : true;
}

View File

@ -8,11 +8,10 @@
*/
import {useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type PeopleItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_HEADING,
@ -20,7 +19,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -33,7 +31,6 @@ export function PeopleFilmstrip(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PeopleItem>(section.type, section.data);
const [picked, setPicked] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 사람으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[picked] ?? parsed.items[0];
@ -70,10 +67,7 @@ export function PeopleFilmstrip(props: SectionRenderProps) {
}}
>
<div className="w4-film-perf h-2.5 opacity-70" />
<div
ref={ref}
className="w4-scroll flex snap-x snap-mandatory gap-3 overflow-x-auto px-3 py-3"
>
<Rail label="인물" tone="dark" nav="overlay" gap={0.75} viewportClassName="px-3 py-3">
{parsed.items.map((person, index) => (
<button
key={`${person.name}-${index}`}
@ -110,14 +104,10 @@ export function PeopleFilmstrip(props: SectionRenderProps) {
</span>
</button>
))}
</div>
</Rail>
<div className="w4-film-perf h-2.5 opacity-70" />
</div>
{parsed.items.length > 4 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} tone="dark" label="인물" />
)}
{/* 고른 프레임의 자막 — 프레임 안에 넣으면 이름조차 안 읽힌다 */}
<div className="space-y-2 border-l-2 pl-4" style={{borderColor: ITEM_ACCENT}}>
<h3 className="text-xl" style={{fontFamily: ITEM_HEADING}}>

View File

@ -6,6 +6,7 @@ import {useState} from 'react';
import {
Lightbox,
PhotoTile,
Rail,
SectionBody,
SectionFrame,
SectionHeading,
@ -23,27 +24,19 @@ export function PhotosCarousel(props: SectionRenderProps) {
<SectionBody width="wide">
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
<div className="scrollbar-none flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-3">
{/* ★ 예전엔 점이 그려져 있기만 하고 아무것도 가리키지 않았다(전부 같은 색·클릭 불가).
지금 몇 번째인지는 Rail 이 숫자로 말하고, 화살표와 드래그로 실제로 넘어간다. */}
<Rail label="사진" gap={0.75}>
{items.map((photo, idx) => (
<PhotoTile
key={photo.id}
photo={photo}
fit="landscape"
onOpen={() => setOpenIndex(idx)}
className="w-[78vw] max-w-[420px] shrink-0 snap-center"
className="w-[78vw] max-w-[420px] shrink-0"
/>
))}
</div>
<div className="flex items-center justify-center gap-1.5">
{items.map((photo) => (
<span
key={photo.id}
className="size-1.5 rounded-full"
style={{backgroundColor: `${template.colors.primary}40`}}
/>
))}
</div>
</Rail>
</SectionBody>
<Lightbox photos={items} index={openIndex} onClose={() => setOpenIndex(null)} onMove={setOpenIndex} />

View File

@ -1,218 +0,0 @@
/**
* 계절별 추천 하루 — 계절 탭 + 순위 카드.
*
* ★ 여행 스케줄(schedule)과 축이 다르다. 저쪽은 사장님이 시각을 적어 둔 시간표고,
* 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다.
* 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다).
* ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다.
* ★ 손님 화면에는 **지금 계절만** 나간다(간절기에는 둘). 여기 탭은 사장님이 나머지 계절을
* 확인하려고 있는 것이라, 처음 열면 지금 계절에 맞춰 두고 그 사실을 아래에 적어 둔다 —
* 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다.
*/
import {useMemo, useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {
currentSeasons,
parseSectionData,
planDay,
plannerSeasons,
plannerTop,
type PlannerItem,
} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_INVERSE_INK,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
const RANK_LABEL = ['1위', '2위', '3위'];
/** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */
function spanText(minutes: number): string {
const hour = Math.floor(minutes / 60);
const rest = minutes % 60;
return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분';
}
function PlanCard({item, rank}: {item: PlannerItem; rank: number}) {
const day = planDay(item);
return (
<article
className="w4-paper w-[320px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(0,0,0,.08)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
{/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면
팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */}
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={
rank === 0
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{RANK_LABEL[rank] ?? `${rank + 1}위`}
</span>
<span className="text-[11px] opacity-60">
{day.from}–{day.to} · {spanText(day.totalMinutes)}
</span>
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{item.name}
</h4>
{item.audience && <p className="text-[11px] opacity-60">{item.audience}</p>}
{item.why && (
<p className="text-[13px] leading-relaxed opacity-80" style={{fontFamily: ITEM_BODY}}>
{item.why}
</p>
)}
</div>
{day.stops.length === 0 ? (
<p className="px-4 py-5 text-center text-[11px] opacity-60">
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p>
) : (
<ol className="mt-3 px-4 pb-3">
{day.stops.map((planned, index) => (
<li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5">
{/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */}
<span className="pt-2 text-[12px] tabular-nums opacity-75" style={{fontFamily: ITEM_HEADING}}>
{planned.time}
</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && (
<p className="pt-1 text-[10px] opacity-50">↓ {planned.move}분 이동</p>
)}
<p className="pt-1 text-sm font-bold" style={{fontFamily: ITEM_BODY}}>
{planned.stop.name}
</p>
{planned.stop.note && (
<p className="mt-0.5 text-[12px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{planned.stop.note}
</p>
)}
<p className="mt-0.5 text-[10px] opacity-50">
{planned.time}–{planned.until}
{planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
)}
<div className="space-y-1 border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
{/* ★ 상한에 걸려 뺀 칸을 숨기지 않는다. 숨기면 사장님은 자기가 적은 곳이 왜 없는지 모른다. */}
{day.dropped > 0 && (
<p className="text-[10px]" style={{color: ITEM_ACCENT}}>
밤 9시를 넘겨 {day.dropped}곳을 뺐습니다 — 머무는 시간을 줄이거나 출발을 당겨 보세요.
</p>
)}
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
);
}
export function PlannerPodium(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PlannerItem>(section.type, section.data);
const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]);
// 지금 계절(간절기면 둘 중 앞선 것)을 처음 탭으로. 손님이 보는 것과 같은 화면에서 시작한다.
const live = useMemo(() => currentSeasons().filter((s) => seasons.includes(s)), [seasons]);
const [picked, setPicked] = useState<number>();
const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? ''));
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
const season = seasons[index] ?? seasons[0];
const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="계절별 추천 하루" />
) : (
<div className="space-y-4">
{seasons.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{seasons.map((name, tabIndex) => (
<button
key={name}
type="button"
onClick={(event) => {
event.stopPropagation();
setPicked(tabIndex);
}}
aria-current={tabIndex === index}
className={cn(
'border px-3 py-1 text-xs transition-opacity',
tabIndex !== index && 'opacity-55',
)}
style={
tabIndex === index
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{name}
{/* 손님 화면에 지금 나가는 계절. 사장님이 '어느 게 실제로 보이나'를 눈으로 안다. */}
{live.includes(name) && <span className="ml-1 opacity-70">·지금</span>}
</button>
))}
</div>
)}
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
{top.map((item, rank) => (
<PlanCard key={`${item.name}-${rank}`} item={item} rank={rank} />
))}
</div>
<p className="text-[11px] opacity-60">
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
{top.length}개 / 전체 {parsed.items.length}개
</p>
{live.length > 0 && (
<p className="text-[11px] opacity-60">
손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다.
</p>
)}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -6,11 +6,10 @@
*/
import {useState} from 'react';
import {Check, Copy} from 'lucide-react';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type PostcardItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
@ -20,7 +19,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -98,7 +96,6 @@ function Postcard({item}: {item: PostcardItem}) {
export function PostcardStack(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PostcardItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
@ -120,19 +117,14 @@ export function PostcardStack(props: SectionRenderProps) {
<PasteHint label="오늘의 엽서" />
) : (
<div className="space-y-3">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
<Rail label="엽서">
{parsed.items.map((item, index) => (
<Postcard key={`${item.line}-${index}`} item={item} />
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] opacity-60">
[복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장
</p>
{parsed.items.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="엽서" />
)}
</div>
</Rail>
<p className="text-[11px] opacity-60">
[복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장
</p>
</div>
)}
</SectionBody>

View File

@ -7,11 +7,10 @@
*/
import {useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type QuizItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
@ -21,7 +20,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -85,7 +83,6 @@ function QuizCard({item, no}: {item: QuizItem; no: number}) {
export function QuizFlip(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<QuizItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
@ -107,19 +104,14 @@ export function QuizFlip(props: SectionRenderProps) {
<PasteHint label="뒤집어 보는 질문" />
) : (
<div className="space-y-3">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
<Rail label="질문">
{parsed.items.map((item, index) => (
<QuizCard key={`${item.question}-${index}`} item={item} no={index + 1} />
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] opacity-60">
정답은 두지 않습니다 — 힌트와 출처까지만 · 총 {parsed.items.length}문항
</p>
{parsed.items.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="질문" />
)}
</div>
</Rail>
<p className="text-[11px] opacity-60">
정답은 두지 않습니다 — 힌트와 출처까지만 · 총 {parsed.items.length}문항
</p>
</div>
)}
</SectionBody>

Some files were not shown because too many files have changed in this diff Show More