Compare commits
6 Commits
7bbeb068a8
...
534122dccf
| Author | SHA1 | Date | |
|---|---|---|---|
| 534122dccf | |||
| 1e0edeef8f | |||
| b1386dd3ce | |||
| 387783b766 | |||
| c4af53613e | |||
| 64ce467f21 |
@ -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`
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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회는 반대로 낭비다.
|
||||
|
||||
@ -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 으로 주고 있었다
|
||||
|
||||
**무슨 일**
|
||||
|
||||
@ -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 기준)
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
76
postgres-init/migrations/0002_spots_shared.sql
Normal file
76
postgres-init/migrations/0002_spots_shared.sql
Normal 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 는 지우지 않는다. 읽는 쪽이 새 테이블로 다 옮겨간 뒤에 별도 번호로 뗀다 —
|
||||
-- 한 번에 갈아엎으면 되돌릴 자리가 없다.
|
||||
75
postgres-init/migrations/0003_site_contents.sql
Normal file
75
postgres-init/migrations/0003_site_contents.sql
Normal 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 으로 떨어지게 한 뒤, 별도 번호로 뗀다.
|
||||
102
postgres-init/migrations/0004_local_contents_unify.sql
Normal file
102
postgres-init/migrations/0004_local_contents_unify.sql
Normal 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 는 남긴다. 읽는 코드가 새 구조로 옮겨간 뒤 별도 번호로 뗀다 —
|
||||
-- 한 번에 갈아엎으면 되돌릴 자리가 없다.
|
||||
85
postgres-init/migrations/0005_flatten_schemas.sql
Normal file
85
postgres-init/migrations/0005_flatten_schemas.sql
Normal 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;
|
||||
27
postgres-init/migrations/0006_prune_unused.sql
Normal file
27
postgres-init/migrations/0006_prune_unused.sql
Normal 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 는
|
||||
-- "언제 무슨 일이 있었나"(동작·결과·반려사유·실행자)다.
|
||||
18
postgres-init/migrations/0007_story_rows_per_kind.sql
Normal file
18
postgres-init/migrations/0007_story_rows_per_kind.sql
Normal 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;
|
||||
@ -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 는 아직 지우지 않는다. 읽는 코드가 옮겨 간 것을 확인한 뒤 별도 번호로 뗀다 —
|
||||
-- 같은 마이그레이션에서 옮기고 지우면, 이관이 틀렸을 때 되돌릴 원본이 없다.
|
||||
34
postgres-init/migrations/README.md
Normal file
34
postgres-init/migrations/README.md
Normal 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` 에 남는다. 이미 있는 번호는 건너뛴다.
|
||||
@ -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": "원" },
|
||||
|
||||
@ -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) 이라
|
||||
|
||||
@ -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):
|
||||
|
||||
24
solution/backend/common/utils/geo.py
Normal file
24
solution/backend/common/utils/geo.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
133
solution/backend/crud/place_content_crud.py
Normal file
133
solution/backend/crud/place_content_crud.py
Normal 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()),
|
||||
)
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
50
solution/backend/crud/site_section_crud.py
Normal file
50
solution/backend/crud/site_section_crud.py
Normal 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(),
|
||||
},
|
||||
),
|
||||
)
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)))
|
||||
|
||||
111
solution/backend/scripts/migrate.py
Normal file
111
solution/backend/scripts/migrate.py
Normal 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)))
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
25
solution/backend/services/external/kakao.py
vendored
25
solution/backend/services/external/kakao.py
vendored
@ -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,
|
||||
|
||||
@ -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` 에 사유와 함께 남는다 — 조용히 버리지 않는다.
|
||||
|
||||
336
solution/backend/services/external/tour_api.py
vendored
336
solution/backend/services/external/tour_api.py
vendored
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
118
solution/backend/services/grounding/story.py
Normal file
118
solution/backend/services/grounding/story.py
Normal 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
|
||||
147
solution/backend/services/itinerary.py
Normal file
147
solution/backend/services/itinerary.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
41
solution/backend/services/prompts/section_prompts.json
Normal file
41
solution/backend/services/prompts/section_prompts.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
61
solution/backend/services/prompts/story.py
Normal file
61
solution/backend/services/prompts/story.py
Normal 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']}"
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
237
solution/backend/services/story_service.py
Normal file
237
solution/backend/services/story_service.py
Normal 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
|
||||
@ -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
|
||||
),
|
||||
|
||||
@ -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)
|
||||
|
||||
144
solution/backend/tests/test_story_generation.py
Normal file
144
solution/backend/tests/test_story_generation.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
2
solution/frontend/public/robots.txt
Normal file
2
solution/frontend/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /admin/
|
||||
10
solution/frontend/public/sitemap.xml
Normal file
10
solution/frontend/public/sitemap.xml
Normal 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>
|
||||
@ -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: '옛 항구',
|
||||
|
||||
@ -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"
|
||||
|
||||
275
solution/frontend/src/features/builder/ItemFormEditor.tsx
Normal file
275
solution/frontend/src/features/builder/ItemFormEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
146
solution/frontend/src/features/builder/SitePreview.tsx
Normal file
146
solution/frontend/src/features/builder/SitePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
solution/frontend/src/features/builder/TemplatePreview.tsx
Normal file
73
solution/frontend/src/features/builder/TemplatePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 그 업종에서 고를 수 있는 배리에이션. 업종 제한이 걸린 건 걸러낸다. */
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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}}>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user