From e0d45eda976bc4f50cedc9de7e474e3bc2d47842 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 10 Sep 2026 11:39:43 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20postgres-init,solution/backend,docs:=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=9E=AC=ED=8E=B8=EC=9D=B4=20?= =?UTF-8?q?=EC=95=88=20=EB=8B=BF=EC=9D=80=20=EC=9E=90=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=84=EB=B6=80=20=EC=9E=A1=EB=8A=94=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?init.sql=20=C2=B7=20ORM=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=C2=B7?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0005 가 도메인 스키마를 걷어내고 표 이름을 옮겼는데, 문자열로 표 이름을 들고 있던 자리들이 따라오지 않았다. import 도 타입검사도 pyflakes 도 못 잡는 종류라 전부 **실행되는 순간에만** 터졌고, 그동안 pytest 는 569건이 통째로 죽어 있어 아무것도 못 잡고 있었다. **init.sql 이 새 DB 를 옛 구조로 세우고 있었다** 64ce467 이 이 파일에 94줄을 더하기만 하고 삭제를 0줄 했다. 그래서 이 파일 한 벌로 세운 DB 는 `place.place_links`·`job.jobs` 를 갖고 ORM 은 `public.place_channels`·`public.jobs` 를 찾는다 — 기동은 정상이고 첫 쿼리에서 죽는다. "init.sql 은 새 DB 를 세우는 전체 DDL 이고 계속 최신을 유지한다"(migrations/README.md)는 계약이 깨져 있었다. - public 한 벌 · 표 14개로 다시 썼다. 옛 스키마가 있는 DB 에서 다시 돌면 RAISE EXCEPTION 으로 멈춘다 — 그대로 두면 public 에 빈 표가 생기고 0005 가 "relation already exists" 로 실패해 데이터가 옛 스키마에 갇힌다 - 말미에 **마이그레이션 기준선**을 심는다. 없으면 새 DB 에서 migrate.py 가 0001 부터 다시 돌다가 `schema "local" does not exist` 로 죽는다 **운영 버그 둘** — 두 DB(새로 세운 것 · 마이그레이션으로 따라온 것)를 pg_dump 로 찍어 비교해 찾았다 - `upsert_weather` 의 ON CONFLICT 술어에 `kind IS NULL` 이 빠져 **날씨 캐시 저장이 계속 실패**하고 있었다(0007 이 인덱스에 그 조건을 더했다). 캐시라 화면이 안 죽고 로그에만 남았다. 포스트그레스는 술어가 인덱스 술어를 함의하는지 보고 아니면 "no unique or exclusion constraint matching" 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보인다 - ORM 의 `area_contents` 인덱스 정의가 0004·0007·0008 을 하나도 안 따라왔다. 테스트 DB 는 이 모델로 세워지므로 **테스트가 운영과 다른 제약 아래에서 돌고 있었다** **0009** — 두 DB 비교에서 나온 어긋남 셋(데이터는 안 건드린다) - `idx_site_contents_site` 가 기존 DB 에만 없었다(0003 이 유니크만 걸었다) — 섹션 조회가 시퀀셜 스캔 - `places.external_place_id` VARCHAR(32) → (64). ORM 은 64 다 — 긴 id 가 잘리면 동일 업소 판정이 틀린다 - RENAME 이 안 따라간 PK 제약 이름 9개(`facts_pkey` → `place_facts_pkey` …) **테스트를 살린다** - conftest 의 TRUNCATE 가 표 이름을 **손으로 나열**하고 있었다. 0005 가 이름을 옮기자 전 테스트가 `relation "place_aliases" does not exist` 로 죽었다 — 이제 ORM 메타데이터에서 뽑아 다시 어긋날 수 없다 - `test_schema_ddl` 이 모델 표를 `"None.users"` 로 조회해 **한 표도 비교하지 않고 통과**하고 있었다. init.sql 이 조용히 어긋난 동안 이 테스트는 초록이었다. 비교한 표 수를 세는 단언을 더한다 - 테스트 SQL 15곳의 옛 표 이름, `_run_worker` 1틱 문제(수집 뒤 따라오는 LOCAL_SYNC 를 집어 가 정작 기다리던 잡이 PENDING 으로 남았다), 지역 캐시 픽스처(읽는 코드가 옳게 거르는데 테스트가 빨개졌다) **문서** - `docs/DATA_MODEL.md` 신설 — 표 14개가 무엇을 담고 누가 쓰는지, 값 하나가 DB 에서 페이지까지 가는 길, 두 번 도는 게이트, **DB 에 없는 것** - `SERVERS.md` DB 절을 마이그레이션 체계로. 배포에 `migrate.py` 를 넣는다 — 코드만 갈면 컨테이너는 정상으로 뜨고 가게 등록·수집·발행만 죽는다 - ARCHITECTURE 2절의 프리렌더 컨테이너가 `solution-frontend` 로 적혀 있었다. 굽는 건 `solution-prerender` 고 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔 그 혼동을 문서가 만들고 있었다 - 옛 표 이름 잔재(`place_links`·`local_contents`·`job.jobs`·`company.users`·`fact.facts`·`ai_check_results`) 검증: 빈 컨테이너에 init.sql 로 세운 DB ↔ 마이그레이션으로 따라온 DB 를 `pg_dump --schema-only` 로 비교 — 표·인덱스·제약·컬럼 전부 동일. pytest 583건 중 581 통과(남은 2건은 `.env` 누수· 레이트리밋 카운터로 환경 문제다). 구글 로그인 21건 포함. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 1 + docs/ARCHITECTURE.md | 2 +- docs/COLLECTION_SEO_AEO_FLOW.md | 2 +- docs/DATA_MODEL.md | 285 +++++++ docs/DECISIONS.md | 10 +- docs/DEVELOPMENT_DIRECTION.md | 2 +- docs/DEVLOG.md | 38 + docs/SERVERS.md | 39 +- postgres-init/init-data/init.sql | 763 ++++++++---------- .../migrations/0009_align_with_init_sql.sql | 43 + .../backend/common/database/model/models.py | 32 +- solution/backend/conftest.py | 30 +- solution/backend/crud/local_content_crud.py | 51 +- solution/backend/services/external/kakao.py | 4 +- solution/backend/services/external/naver.py | 4 +- solution/backend/tests/test_build_publish.py | 4 +- .../backend/tests/test_collect_pipeline.py | 15 +- solution/backend/tests/test_copy_api.py | 4 +- solution/backend/tests/test_fact_schema.py | 10 +- solution/backend/tests/test_faq_api.py | 2 +- solution/backend/tests/test_schema_ddl.py | 31 +- solution/backend/tests/test_snapshot.py | 69 +- .../backend/tests/test_tour_api_adapter.py | 20 +- solution/backend/tests/test_vision_api.py | 4 +- solution/backend/tests/test_weather_api.py | 2 +- 25 files changed, 907 insertions(+), 560 deletions(-) create mode 100644 docs/DATA_MODEL.md create mode 100644 postgres-init/migrations/0009_align_with_init_sql.sql diff --git a/AGENTS.md b/AGENTS.md index 0413d1f..8d169a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ |---|---| | 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) | | 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | +| **어느 표 어느 칸**에 담기나 · 값이 페이지까지 가는 길 | [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | | **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) | | 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) | | 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 96df61c..2200a66 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build() ├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate() ├ site_payload.emit_payload() → out/payloads/.json ★ 백엔드의 유일한 산출물 │ - │ ┌ [별도 컨테이너 o2o-web4ai-solution-frontend] + │ ┌ [별도 컨테이너 solution-prerender] ★ solution-frontend 가 아니다(개발용) │ │ scripts/watch-payloads.mjs — payloads/ 2초 폴링, 바뀐 것만 │ │ └ node dist/prerender/prerender.js --payload= │ │ → out/s//** + out/assets, out/fonts, out/robots.txt … diff --git a/docs/COLLECTION_SEO_AEO_FLOW.md b/docs/COLLECTION_SEO_AEO_FLOW.md index 31167ad..8b5caa4 100644 --- a/docs/COLLECTION_SEO_AEO_FLOW.md +++ b/docs/COLLECTION_SEO_AEO_FLOW.md @@ -52,7 +52,7 @@ - 검색 대상: 야놀자, 여기어때, 네이버 플레이스 등 허용된 도메인 - 제외 대상: 서비스 홈, 목록, 블로그·카페 후기 -Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 않는다. URL 후보만 `place_links`에 미확정 상태로 저장한다. +Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 않는다. URL 후보만 `place_channels`에 미확정 상태로 저장한다. 사용자가 `내 채널 맞아요`로 확인한 링크만 크롤링과 사이트 노출에 사용한다. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..019f8fa --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,285 @@ +# DATA MODEL — 값이 DB 에서 페이지까지 가는 길 + +**이 문서 하나만 읽고도 "이 값이 어느 표 어느 칸에 있고, 왜 화면에 나왔거나 안 나왔는지" 를 +짚을 수 있어야 한다.** + +- 파이프라인의 *마지막 구간*(BUILD 잡 내부)은 [ARCHITECTURE 2절](ARCHITECTURE.md)이 단일 출처다. + 여기서는 그 앞뒤를 잇는다. +- 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md). +- 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md). + +정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`) +과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다. + +--- + +## 0. 표 14개, 스키마는 `public` 한 벌 + +도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다. +스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다. +소속은 **이름**이 말한다(`place_*` · `site_*` · `area_*`). + +``` +users 사장님 계정 +├ places 사업장 — 모든 것의 스코프 키 +│ ├ place_channels 채널 URL(네이버·TourAPI·홈페이지) — 크롤링 대상 +│ ├ place_units 객실 · 메뉴 · 프로그램 +│ ├ place_photos 사진 +│ ├ place_facts ★ 사실. 이 제품의 심장 +│ ├ place_faqs FAQ +│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만 +├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님) +└ sites 발행 사이트 — 사업장당 1개 + ├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것) + ├ site_versions ★ 빌드 버전 — snapshot 박제 + └ site_publish_logs 발행 시도 기록(반려 사유 포함) +jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 +``` + +**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고, +자연키 유니크는 `deleted = false` 부분 인덱스로 건다 — `jobs` 만 예외다(잡은 이력이라 안 지운다). + +--- + +## 1. 한 장으로 보는 흐름 + +``` +[사장님 화면] [백엔드] [산출물] + +가게 이름 입력 + └ POST /v1/place ─────────→ places 행 생성 (status=DRAFT) +카카오 로컬에서 내 가게 선택 + └ POST .../verify ────────→ places.verified_at · latitude/longitude + · region_code · external_category 박제 + ★ verified_at 이 NULL 이면 이 아래로 못 간다 + +[수집 시작] ────────────────→ jobs(COLLECT) 적재 + worker: services/collect_service.run_collect + ├ 네이버 플레이스·TourAPI 직접 해석 → place_channels + ├ (선택) Perplexity 로 URL 후보 발견 → place_channels.raw + ├ 확정된 URL 만 크롤링 → place_facts · place_photos + └ 하위 단위 자동 생성 → place_units + 이어서 jobs(VISION) → place_photos.label/alt_text/status + jobs(COPY) → place_faqs · 소개문 place_facts + jobs(LOCAL_SYNC) → area_contents (지역당 1회) + +[에디터] + 템플릿 고르기 ─────────────→ sites.template_id + 색·서체·섹션 순서/on-off ──→ sites.theme (JSONB) + 섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행) + 주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m + 미리보기 ──────────────────→ GET /v1/place/{id}/site/preview + ★ 발행과 **같은 함수**로 payload 를 만든다(DB 를 안 건드린다) + +[발행하기] ─────────────────→ jobs(BUILD, publish=true) + services/build_service.run_build ── 아래 3절 + └ out/payloads/.json ★ 백엔드의 유일한 산출물 + │ + │ (컨테이너 경계) + ▼ + solution-prerender 컨테이너 + scripts/watch-payloads.mjs → prerender.ts + │ + ▼ + out/s//index.html · llms.txt + out/sitemap.xml · robots.txt · /s (목록) +``` + +★ **컨테이너 이름을 헷갈리지 않는다.** 굽는 것은 `solution-prerender` 다. +`solution-frontend` 는 개발용(`profiles: ["dev"]`)이라 운영에서 아예 뜨지 않는다 — +`restart solution-frontend` 는 **아무 일도 안 하면서 성공한다.** + +--- + +## 2. 표별 — 무엇을 담나 · 누가 쓰나 · 어디로 나가나 + +### `places` — 모든 것의 스코프 키 + +| 칸 | 무엇 | 쓰이는 곳 | +|---|---|---| +| `owner_user_id` | 사장님 계정 | **스코프 키.** 조회는 전부 이 값으로 좁힌다(회사/테넌트를 걷어내고 이 컬럼이 그 자리를 받았다) | +| `category` | 업종 코드 | 업종 스키마 선택(`common/category_schema`) — 어떤 fact key 가 허용되는지, 어떤 섹션을 기본으로 켜는지 | +| `verified_at` | 카카오 로컬 검증 시각 | ★ **NULL 이면 수집도 발행도 금지.** 검증 없이 수집하면 남의 가게가 섞인다 | +| `latitude`/`longitude` | 좌표 | 빌드 시점 TourAPI 반경 조회(주변 맛집·축제·관광지) | +| `region_code` | 행정구역 코드 | ★ **지역 콘텐츠 캐시 키.** 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회 | +| `external_category` | 외부 DB 분류 원문 | 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준 | +| `content_updated_at` | 노출값이 마지막으로 바뀐 시각 | 개별 재빌드 대상 판별 — `site_versions.built_at < content_updated_at` 인 사이트만 다시 굽는다 | + +### `place_facts` — 이 제품의 심장 + +모든 사실은 **값과 함께 출처·수집시각·검증상태**를 갖는다. 출처 없는 사실은 규칙 위반이다. + +| 칸 | 무엇 | 쓰이는 곳 | +|---|---|---| +| `key` | 업종 스키마에 정의된 필드 키 | 스키마에 없는 key 는 저장 자체가 거부된다 | +| `value` · `unit` | 값과 단위 | 화면 · JSON-LD · llms.txt 가 **같은 값**을 쓴다 | +| `status` | 1 UNVERIFIED / 2 PENDING_OWNER / **3 VERIFIED** / **4 CORRECTED** / 5 REJECTED / 6 EXPIRED | ★ **3·4 만 사이트에 나간다**(`PUBLISHABLE_FACT_STATUSES`). 4 는 사장님이 고친 값이라 **잠긴다** — 재수집이 덮어쓰지 못한다 | +| `source_type` · `source_url` | 출처 | payload 에 그대로 실어 화면이 "언제 무엇으로 확인된 값인지" 를 보여준다 | +| `unit_id` | NULL 이면 사업장 fact, 있으면 객실·메뉴 fact | 객실별 요금·정원이 여기로 들어간다 | +| `expires_at` | 유효기간 | 지나면 EXPIRED 로 내려 재수집 대상이 된다 | + +활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스). +후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다. + +### `place_channels` — 크롤링 대상 URL + +`confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다. +`raw` 에는 Perplexity 응답을 통째로 박제하지만 **사실 근거로 쓰지 않는다** — 환각 추적용이다. + +### `place_photos` — 사진 + +`status` 가 `APPROVED`(2) 인 것만 사이트에 나간다. Gemini Vision 신뢰도가 낮으면 +`PENDING_REVIEW`(1) 로 남아 빌드에서 빠진다. `source_type`·`origin_url` 을 반드시 남긴다 — +크롤링 이미지의 재게시 권리가 미결이라([DECISIONS 1-2](DECISIONS.md)) 결론에 따라 걸러낼 수 있어야 한다. + +### `place_faqs` — FAQ + +`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이 +데이터 모양으로 강제된 자리다. + +### `area_contents` + `place_area_refs` — 지역 콘텐츠 + +★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회. + +| 종류(`content_type`) | 출처 | `kind` | +|---|---|---| +| 1 WEATHER | Open-Meteo | — | +| 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — | +| 6 STORY | Perplexity | `songs` `people` `chronicle` `postcard` `quiz` | + +`body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다. + +`place_area_refs` 에는 **업장마다 다른 것만** 둔다 — `distance_m`(정렬·도보시간의 원값)과 +`hidden`. 예전에는 이 표가 값을 통째로 들고 있어서(`place_contents`) 업장마다 TourAPI 응답이 +복제됐다 — 실측(2026-09-09) 한 곳에 144행. `hidden` 은 재수집이 덮어쓰지 않는다. + +★ **외부 API 가 실패해도 이 행을 지우거나 비우지 않는다.** 직전 값을 유지하고 알림만 낸다. + +### `sites` — 발행 사이트(사업장당 1개) + +| 칸 | 무엇 | 왜 서버에 두나 | +|---|---|---| +| `template_id` | 사장님이 고른 템플릿 키 | 서버는 **해석하지 않고 보관·반환만** 한다. 템플릿 목록은 프론트가 소유하므로, 서버가 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다 | +| `theme` (JSONB) | 색·서체·**섹션 순서/on-off/배리에이션** | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 | +| `status` | 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED | ★ 해지는 **물리 삭제가 아니라 상태 전이**다 — 색인된 페이지를 갑자기 404 로 만들지 않는다 | +| `current_version_id` | 지금 나가 있는 버전 | | +| `thumbnail_url` | 쇼케이스 카드 그림 | ★ **발행에 성공한 뒤에만** 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 | + +★ `templateId` 를 `theme` 안에 넣지 않는다. 두 곳에 두면 어느 쪽이 진짜인지 갈린다. + +### `site_sections` — 섹션 콘텐츠 (`sites.theme` 와 역할이 다르다) + +**`theme` 은 모양, 여기는 내용.** 2026-09-09 에 갈랐다 — 실측(`/s/stay`): `theme` 42,150 B 중 +디자인이 636 B(1.5%), 콘텐츠가 39,645 B(94%)였다. 크기가 아니라 **쓰기 단위**가 문제였다: +영상 주소 하나(592 B)를 고쳐도 42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, +항목마다 "누가 넣었나 · 확인됐나" 를 물을 자리가 없었다. + +`(site_id, section_id)` 당 1행. `section_id` 는 `songs` `itinerary` `video` `people` `local` …. +`data` 는 shared 의 `XxxItem[]` 계약을 그대로 담는다. +`shared_ref` 가 있으면 값을 복제하지 않고 원본(`area_contents`)을 가리키고, 발행할 때 펼친다. + +★ `section_id = 'local'` 의 `data.places` 는 **배열이 아니라 맵**이다 — 화면에 순서대로 서는 +항목이 아니라 `ref → 값` 조회표다. 정렬 기준은 읽는 쪽이 갖는다. + +### `site_versions` — 빌드 버전, 그리고 정적 빌드의 경계 + +| 칸 | 무엇 | +|---|---| +| `snapshot` (JSONB) | ★ **빌드 시점 데이터 박제.** 방문자는 DB 와 만나지 않는다 | +| `jsonld` | 렌더러가 **실제로 내보낸** 구조화 데이터. 백엔드가 따로 계산하지 않는다 | +| `unique_content_count` | 렌더러가 센 고유 콘텐츠 수. **0 이면 발행 거부**(스팸 판정 대상) | +| `build_status` | PENDING → BUILDING → BUILT / FAILED | +| `build_error` | 실패 사유 원문 | +| `built_at` | `places.content_updated_at` 과 비교해 재빌드 대상을 고른다 | + +`snapshot` 이 감사 기록이기도 하다 — fact 마다 `status`·`source_type`·`source_url`·`verified_at` +을 같이 싣는다. "왜 이 값이 나갔나" 를 나중에 되짚을 수 있어야 하기 때문이다. + +### `site_publish_logs` — 발행 시도 기록 + +게이트가 막았으면 `result=REJECTED` + `reject_reason` + `detail`(막힌 항목 목록)을 남긴다. +화면의 반려 카드가 이 사유 코드로 문구를 고른다 — 전부 "렌더 실패" 로 뭉개면 사장님이 +손댈 곳을 모른다. + +### `jobs` — 작업 큐 (PostgreSQL 을 큐로) + +| `job_type` | 핸들러 | 하는 일 | +|---|---|---| +| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 | +| 2 VISION | `vision_service.run_vision` | 사진 분류 + alt 생성 | +| 3 COPY | `copy_service.run_copy` | 소개문·FAQ (확보된 fact 만 근거) | +| 4 BUILD | `build_service.run_build` | ★ 정적 빌드 + 발행 게이트 | +| 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) | +| 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 | + +- 할당은 **단일 문장 원자 claim**(`FOR UPDATE SKIP LOCKED` + 같은 UPDATE + `RETURNING`) — + 워커가 몇 개든 이중 할당이 불가능하다. +- 복구는 타임아웃 추측이 아니라 **`lease_until` 만료 소유권**이다. 컨테이너를 재시작해도 + 진행 중이던 잡이 증발하지 않는다. +- `dedupe_key` 로 활성 중복(PENDING/RUNNING)을 막는다 — 지역 이야기는 `story:{region_code}` 라 + 같은 지역 숙소 50곳이 동시에 열어도 잡은 하나다. +- ★ 이 표만 raw SQL 경로가 있다. 표 이름을 옮기면 ORM 이름 변경이 **여기까지 안 따라온다** — + 2026-09-09 에 `job.jobs` → `jobs` 를 놓쳐 큐가 통째로 멈췄다(화면에는 "버튼만 안 먹는" 것으로 보였다). + +--- + +## 3. 값 하나가 페이지까지 가는 길 + +``` +place_facts (status=3 or 4) ← 이 필터가 snapshot.py 한 곳에만 있다 + └ build_snapshot() services/snapshot.py:59 + · fact : VERIFIED / CORRECTED 만 + · 사진 : APPROVED 만 + · FAQ : VERIFIED / CORRECTED 만 + · 지역 : PUBLISHED + 노출기간 안 + 종류별 20건까지 + └ site_versions.snapshot 에 박제 + └ to_site_payload() services/site_payload.py:708 + ★ 여기서 DB 를 다시 읽지 않는다 — 입력은 박제된 스냅샷뿐이다. + 다시 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 "스냅샷과 다른 페이지" 가 나온다 + └ out/payloads/.json + └ prerender.ts → out/s//index.html + 화면 · JSON-LD · llms.txt 가 **같은 값**에서 나온다 +``` + +게이트는 **두 번** 돈다. + +1. **1차 (렌더 전, DB 사실 기준)** — 상호명·업종·미검증 fact. + payload 를 쓰기 **전에** 막는다. 렌더러에 넘긴 뒤 막으면 검증 안 된 값이 디스크에 한 번 나갔다 온다. +2. **2차 (렌더 후, 실제로 구워진 HTML 기준)** — JSON-LD 불일치 · 고유 콘텐츠 수. + 1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다. + +★ 지역 정보(주변 맛집·축제)는 **빌드 시점에 업장 좌표로 새로 받는다.** 실패해도 빌드는 계속한다 — +곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고, 직전 값이 그대로 있다. + +--- + +## 4. DB 에 **없는** 것 + +경계를 아는 것이 표를 아는 것만큼 중요하다. + +| 것 | 어디 있나 | +|---|---| +| HTML · 사이트맵 · llms.txt | `out/` 디렉토리. **백엔드는 HTML 을 만들지 않는다** | +| 렌더링 결과 보고서 | `out/payloads/.status/.json` (프리렌더 → 백엔드 단방향) | +| 섹션 목록 · 배리에이션 키 · 색 토큰 이름 | 프론트가 소유. 서버는 `theme` JSONB 로 통째로 보관만 | +| 빈 방 재고 · 예약 접수 · 결제 | **어디에도 없다.** 예약 섹션은 화면 목업이고 연동이 없다 | +| 방문자 세션 | 없다. 정적 페이지라 방문자는 DB 와 만나지 않는다 | + +--- + +## 5. 표를 고칠 때 + +1. ORM(`models.py`) 과 `init.sql` **둘 다** 고친다. +2. 이미 데이터가 든 DB 를 위해 `postgres-init/migrations/NNNN_*.sql` 을 더한다. +3. 적용: `cd solution/backend && .venv/bin/python scripts/migrate.py` + (서버는 [SERVERS.md `## DB`](SERVERS.md) 참조) + +★ **표 이름을 옮겼으면 정적 검사를 돌린다.** import 도 타입검사도 안 잡는 자리가 셋 있다 — +raw SQL, 클래스 생성자, 그리고 표와 이름만 같은 속성. + +```bash +cd solution/backend && python -m pyflakes services/ crud/ router/ worker/ common/ | grep "undefined name" +``` + +2026-09-09 에 이걸 안 돌려서 19건이 남았고, 가게 등록 · 수집 시작 · 수집 완료 세 곳이 연달아 +죽었다. 기동은 정상이라 로그를 열기 전에는 안 보였다. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4888ded..ec820d4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -72,7 +72,7 @@ | 상태 | **미결** (2026-09-02 구글 로그인 붙이면서 생김) | | 필요한 결론 | 이미 id/pw 로 가입한 사람이 같은 이메일의 구글로 로그인했을 때, 같은 계정으로 이을 것인가. 이으려면 **먼저 가입한 쪽의 소유 증명**(비밀번호 재입력 또는 이메일 인증)을 어디에 둘 것인가 | | 왜 지금 안 푸나 | 이메일만 보고 자동으로 이으면 **계정 선점**이 된다 — 공격자가 남의 이메일로 id/pw 계정을 먼저 만들어 두면, 그 사람이 구글로 로그인하는 순간 공격자가 비밀번호를 아는 계정 안으로 들어간다. 소유 증명 절차 없이 열 수 있는 문이 아니다 | -| 코드 격리 | `company.users.provider`(AuthProvider) 로 계정마다 수단을 하나만 둔다. 이메일이 이미 쓰이고 있으면 **잇지도 만들지도 않고** `ACCOUNT_PROVIDER_CONFLICT` 로 거절하고, 화면은 "처음 가입할 때 쓴 방법으로 로그인" 을 안내한다. 반대 방향(구글 계정에 비밀번호 설정)도 `update_me` 에서 같은 코드로 막는다 | +| 코드 격리 | `users.provider`(AuthProvider) 로 계정마다 수단을 하나만 둔다. 이메일이 이미 쓰이고 있으면 **잇지도 만들지도 않고** `ACCOUNT_PROVIDER_CONFLICT` 로 거절하고, 화면은 "처음 가입할 때 쓴 방법으로 로그인" 을 안내한다. 반대 방향(구글 계정에 비밀번호 설정)도 `update_me` 에서 같은 코드로 막는다 | | 결론이 "잇는다" 일 때 | `provider`·`provider_uid` 를 users 에서 별도 테이블(`user_identities`)로 빼고, 계정 하나에 수단 여러 개를 매단다. 지금 구조가 그 이행을 막지 않는다 | | 확정 사항 | **구글 ID 토큰의 `aud`(우리 client_id)와 `email_verified` 검증은 결론과 무관하게 필수다.** `tests/test_google_identity.py` 가 이 둘을 고정한다 | @@ -88,7 +88,7 @@ | 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 | | DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 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` 에 의존한다 | +| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `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__` 를 건드려서 따로 둔다 | | ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 | @@ -121,7 +121,7 @@ | 유니크 인덱스 2분할 | `unit_id IS NULL` / `IS NOT NULL` 로 나눠 건다 | Postgres 에서 NULL 끼리는 유니크가 안 걸린다. 나누지 않으면 사업장 단위 fact 가 중복된다 | | `critical` 플래그 | 업종 스키마 필드 속성으로 도입 | 절대규칙 1(미검증 fact 노출 금지)의 대상 목록이 코드가 아니라 데이터에 있어야 업종 추가 시 자동으로 따라온다 | | `allow_llm` 플래그 | 기본 `False`. `True` 는 소개문 계열 2개뿐 | 절대규칙 7(LLM 은 사실을 만들지 않는다)을 스키마 레벨에서 강제. 테스트가 `required` 필드의 `allow_llm=True` 를 금지한다 | -| 지역 정보 캐시 키 | `local_contents.region_code` (place_id 아님) | 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회. 카카오 키워드 검색이 좌표 변환보다 4배 비싸다 | +| 지역 정보 캐시 키 | `area_contents.region_code` (place_id 아님) | 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회. 카카오 키워드 검색이 좌표 변환보다 4배 비싸다 | | 스키마 네임스페이스 | `place` / `fact` / `local` / `site` | 원본의 도메인별 schema 컨벤션. `local` 은 Postgres 비예약어라 그대로 쓸 수 있다(확인함) | | 테이블명 | 복수형 (`places`, `facts`) | 원본이 복수형(`companies`, `users`, `quotations`). 스펙 문서의 단수 표기는 엔티티 이름으로 읽었다 | | `server_default` | 신규 도메인 테이블에만 추가 | ORM `default=` 는 Python 쪽이라 raw INSERT 에 안 먹는다. `create_all`(테스트 DB)과 `init.sql`(실 DB)이 갈라져서 실제로 버그가 났다. **`companies`/`users` 는 원본 그대로 두었다** | @@ -132,7 +132,7 @@ ### 아직 테이블이 없는 것 - **`report` 스키마** — 노출 리포트·유입 통계(GA4 Data API, Search Console API). 작업 순서 6번 이후. -- ~~**작업 큐**~~ → `job.jobs` 로 생겼다 (2026-08-27, 5-2). 17번째 테이블이다. +- ~~**작업 큐**~~ → `jobs` 로 생겼다 (2026-08-27, 5-2). 지금은 14개 표 중 하나다([DATA_MODEL.md](DATA_MODEL.md)). - **TourAPI areaCode ↔ 카카오 행정구역 코드 매핑** — 테이블 대신 `common/category_schema` 와 같은 리소스 JSON 으로 두는 것을 제안. 3번 참고. --- @@ -172,7 +172,7 @@ | 승인 | 후보 → 노출값, 옛 값 EXPIRED | `PUBLISHED_REPLACED` | | 수정(사람 직접) | 즉시 노출값 교체 | `PUBLISHED_REPLACED` | -스키마: `postgres-init/init-data/init.sql` (`fact.facts` 활성 유니크 + 후보 상태) +스키마: `postgres-init/init-data/init.sql` (`place_facts` 활성 유니크 + 후보 상태) ### 5-2. 그 밖의 결정 diff --git a/docs/DEVELOPMENT_DIRECTION.md b/docs/DEVELOPMENT_DIRECTION.md index 3533602..04c1a09 100644 --- a/docs/DEVELOPMENT_DIRECTION.md +++ b/docs/DEVELOPMENT_DIRECTION.md @@ -64,7 +64,7 @@ | A6 JSON-LD | **구현** | 업종별 JSON-LD, FAQPage, Breadcrumb, WebPage, sameAs | 법률·의료 도입 시 타입·자격·저자 스키마 확장 | | A7 3-way 일치성 | **부분 구현** | JSON-LD↔표시 텍스트 검증, 생성문↔fact 근거 검사, publish gate | 원본↔발행본 SimHash 중복도, 모든 사실 문장의 fact ID 역참조 보고서 | | A8 배포 | **대부분 구현** | 프리렌더 정적 HTML, canonical, sitemap, robots, llms.txt, IndexNow, nginx/Azure 경로 | 고객 도메인 서브패스·서브도메인 연결, TLS/DNS 자동화, Search Console 제출 자동화 여부 | -| A9 모니터링·변경 감지 | **미구현** | `ai_check_results` 테이블과 `AI_CHECK` enum은 있으나 worker handler·보고 모듈 없음 | CDN 크롤러 로그 수집, 원본 hash 비교, fact 만료/재검토, 재생성 큐 | +| A9 모니터링·변경 감지 | **미구현** | `AI_CHECK` enum 만 있고 worker handler·보고 모듈 없음. 표(`ai_check_results`)는 한 번도 안 써서 마이그레이션 0006 이 뗐다 — 붙일 때 다시 만든다 | CDN 크롤러 로그 수집, 원본 hash 비교, fact 만료/재검토, 재생성 큐 | ### 3-2. Brand AEO B1~B9 diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 776bb61..a172cad 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -77,6 +77,44 @@ DB 를 먼저 세워서 함께 막힌다 — 환경 문제이고 별건이다. JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후 `/s/` 에서 데모 껍데기와 안내 문구 확인. +--- + +## 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 으로 주고 있었다 **무슨 일** diff --git a/docs/SERVERS.md b/docs/SERVERS.md index 39f2fa3..46e8fd1 100644 --- a/docs/SERVERS.md +++ b/docs/SERVERS.md @@ -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 기준) diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 8a258a7..f625d61 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -1,24 +1,40 @@ --- 단일 초기화 파일 — 스키마 DDL 전부를 이 한 파일로 적용한다. --- 전부 IF NOT EXISTS 라 재실행 안전. 기존 DB 에 재실행하면 말미의 "기존 DB 보정(ALTER)" 섹션이 최신 스키마로 맞춰준다. --- 컬럼 추가/타입 변경은 postgres-init/alters/YYYY-MM-DD-<주제>.sql 로 따로 쌓는다. +-- 새 DB 를 세우는 전체 DDL — **이 파일 한 벌이 최신 스키마다.** -- --- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다. --- postgres (1개 서버, 5432) --- └── web4ai_db --- ├── company : users --- ├── place : places, place_aliases, place_links, units, media --- ├── fact : facts, faqs --- ├── local : local_contents, routes, nearby_links --- ├── site : sites, site_versions, publish_logs, ai_check_results --- └── job : jobs (작업 큐 — PostgreSQL 을 큐로) --- (예정: report — reports 모듈이 붙을 때 추가) +-- ★ 이 파일은 DB 를 **처음 만들 때만** 돈다(postgres 이미지의 초기화 훅). +-- 이미 데이터가 든 DB 는 `postgres-init/migrations/NNNN_*.sql` 로 따라온다. +-- 표를 고칠 때는 **둘 다** 고친다 — 한쪽만 고치면 새로 세운 DB 와 서버 DB 가 조용히 갈라진다. +-- 규칙과 적용법: postgres-init/migrations/README.md +-- +-- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db), **스키마는 public 한 벌**. +-- +-- users 사람 +-- jobs 작업 큐 (PostgreSQL 을 큐로) +-- places 사업장 — 모든 것의 스코프 키 +-- ├ place_channels 채널 URL(네이버·TourAPI·홈페이지) +-- ├ place_units 객실 · 메뉴 · 프로그램 +-- ├ place_photos 사진 +-- ├ place_facts ★ 사실. 값 + 출처 + 검증상태 +-- ├ place_faqs FAQ +-- └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김) +-- area_contents ★ 지역 콘텐츠 실체 — 접두어 없는 유일한 표. 그게 공유물이라는 표시다 +-- sites 발행 사이트 (사업장당 1개) +-- ├ site_sections 섹션 콘텐츠 +-- ├ site_versions ★ 빌드 버전 — snapshot 박제 +-- └ site_publish_logs 발행 시도 기록 +-- +-- ★ 도메인별 스키마(company·place·fact·local·site·job)는 2026-09-09 에 걷어냈다(migrations/0005). +-- 19개 중 9개가 스키마 이름을 다시 말했고(place.places · fact.facts · job.jobs …), +-- 무엇보다 **경계가 조인 방향과 반대**였다 — places.place_id 를 참조하는 표 11개 중 +-- place 스키마 안에 있는 것이 하나도 없었다. 지금은 **접두어가 소속을 말한다.** -- -- 설계 컨벤션 --- - 단일 DB 안에서 도메인별 schema 로 묶는다. 테이블은 schema 한정자로 참조한다. --- - FK 제약은 걸지 않고 관계 컬럼만 둔다 (무결성은 애플리케이션 레이어에서 관리). schema 간 관계도 동일. --- - 코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다 (1부터; 매핑은 애플리케이션 enum 기준, CHECK 없음). --- - 소프트 삭제(deleted BOOLEAN)를 사용하므로 자연키 유니크는 부분 인덱스로 건다. --- - 시각은 TIMESTAMPTZ, 금액은 BIGINT, 비율은 NUMERIC 으로 둔다. +-- - FK 제약은 걸지 않고 관계 컬럼만 둔다 (무결성은 애플리케이션 레이어에서 관리). +-- - 코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다 (1부터; 매핑은 애플리케이션 enum, CHECK 없음). +-- - 소프트 삭제(deleted BOOLEAN)를 쓰므로 자연키 유니크는 **부분 인덱스**로 건다. +-- - 시각은 TIMESTAMPTZ, 금액은 BIGINT, 비율은 NUMERIC. +-- - 인덱스 이름은 옛 표 이름을 그대로 쓴다(idx_facts_place · uq_place_links_place_url …). +-- 0005 의 RENAME 이 인덱스를 따라 옮겼을 뿐 이름을 바꾸지 않았고, 서버 DB 에 그 이름으로 +-- 이미 있다 — 여기서만 새 이름을 쓰면 같은 인덱스가 두 벌 생긴다. -- PostgreSQL 은 CREATE DATABASE IF NOT EXISTS 를 지원하지 않으므로, -- 존재하지 않을 때만 생성하도록 psql \gexec 로 처리한다 (재실행 안전). @@ -32,115 +48,137 @@ SELECT 'CREATE DATABASE web4ai_db' ALTER DATABASE web4ai_db SET timezone TO 'UTC'; SET timezone TO 'UTC'; --- uuid 기본값(gen_random_uuid) 사용을 위한 확장 (public 스키마에 설치) +-- uuid 기본값(gen_random_uuid) 사용을 위한 확장 CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ============================================================ --- 스키마 (도메인별 네임스페이스) +-- ★ 옛 스키마가 있는 DB 에서는 여기서 멈춘다 -- ============================================================ -CREATE SCHEMA IF NOT EXISTS company; -CREATE SCHEMA IF NOT EXISTS place; -CREATE SCHEMA IF NOT EXISTS fact; -CREATE SCHEMA IF NOT EXISTS local; -CREATE SCHEMA IF NOT EXISTS site; -CREATE SCHEMA IF NOT EXISTS job; +-- 이 파일은 이제 public 에 표를 만든다. 옛 구조(place.places …)가 남은 DB 에 그대로 돌리면 +-- **public 에 빈 표가 새로 생기고**, 뒤이어 0005 의 `place.places SET SCHEMA public` 이 +-- "relation already exists" 로 실패한다 — 데이터는 옛 스키마에 갇히고 앱은 빈 표를 본다. +-- 그런 DB 는 init.sql 이 아니라 마이그레이션으로 따라와야 한다. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'place') THEN + RAISE EXCEPTION + '옛 도메인 스키마(place …)가 있는 DB 다. init.sql 을 다시 돌리지 말고 마이그레이션으로 따라오게 한다: cd solution/backend && .venv/bin/python scripts/migrate.py'; + END IF; +END $$; -- ============================================================ --- company : 계정 --- ★ 스키마 이름만 company 다. 회사(테넌트) 개념은 2026-09-08 에 걷어냈다 — --- 스키마 rename 은 모든 모델의 __table_args__ 를 건드려야 해서 따로 둔다. +-- 사람 -- ============================================================ -CREATE TABLE IF NOT EXISTS company.users ( - user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) - id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_) - password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL - name VARCHAR(50) NULL, -- 이름 - email VARCHAR(255) NULL, -- 이메일 - contact_number VARCHAR(20) NULL, -- 연락처 - last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 마지막 접속 시각 - status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) - role SMALLINT NOT NULL DEFAULT 1, -- 권한(UserRole): 1=user(일반), 2=owner(최고관리자), 3=developer(내부 운영) - provider SMALLINT NOT NULL DEFAULT 1, -- 로그인 수단(AuthProvider): 1=local(id/pw), 2=google - provider_uid VARCHAR(255) NULL, -- 구글 sub — 이메일이 바뀌어도 유지되는 유일 키 - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) - deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 +CREATE TABLE IF NOT EXISTS public.users ( + user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) + id VARCHAR(64) NOT NULL, -- 로그인 ID. ★ 구글 계정은 google_ 라 28자까지 간다 — 20자였을 때 앞자리가 같은 두 계정이 겹쳤다 + password VARCHAR(255) NULL, -- bcrypt 해시. ★ 소셜 계정은 NULL 이다 — 더미 해시를 넣으면 id/pw 로그인이 그 계정을 계속 노린다 + name VARCHAR(50) NULL, + email VARCHAR(255) NULL, + contact_number VARCHAR(20) NULL, + last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status SMALLINT NOT NULL DEFAULT 1, -- UserStatus: 1=active 2=inactive + role SMALLINT NOT NULL DEFAULT 1, -- UserRole: 1=user 2=owner 3=developer + provider SMALLINT NOT NULL DEFAULT 1, -- AuthProvider: 1=local(id/pw) 2=google + provider_uid VARCHAR(255) NULL, -- 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일 키 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE ); -- ============================================================ --- place : 사업장 / 별칭 / 채널 링크 / 하위 단위 / 사진 +-- 작업 큐 — 원자적 CAS claim + lease 소유권 + dead-letter +-- 수집·비전분석·빌드는 몇 분 걸린다. 동기 요청으로 처리하지 않는다. +-- 워커가 죽어도 lease 가 만료되면 reaper 가 회수한다. -- ============================================================ -CREATE TABLE IF NOT EXISTS place.places ( - place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK) - owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.users.user_id) - name VARCHAR(200) NOT NULL, -- 상호명(입력값) - category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과 - status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended - external_source SMALLINT NULL, -- 검증에 쓴 외부 장소 DB(ExternalPlaceSource): 1=kakao 2=naver - external_place_id VARCHAR(64) NULL, -- 외부 고유 장소 id — ★ 카카오는 주고 네이버는 안 준다 - road_address VARCHAR(255) NULL, -- 도로명 주소 - address VARCHAR(255) NULL, -- 지번 주소 - phone VARCHAR(30) NULL, -- 대표 전화 - 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, -- ★ 노출값이 마지막으로 바뀐 시각 — 개별 재빌드 대상 판별용 - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) - deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 +CREATE TABLE IF NOT EXISTS public.jobs ( + job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- ★ 이 표만 server_default 가 꼭 필요하다 — 큐 전이가 raw SQL(RETURNING)이라 ORM 의 파이썬 default 가 안 먹는다 + job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check + status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead + priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선 + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + result JSONB NULL, + dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크). 지역 이야기는 story:{region_code} + attempts SMALLINT NOT NULL DEFAULT 0, -- claim 시 +1 + max_attempts SMALLINT NOT NULL DEFAULT 3, -- 소진되면 DEAD + run_after TIMESTAMPTZ NOT NULL DEFAULT now(), -- 이 시각 이후에만 claim(백오프) + lease_until TIMESTAMPTZ NULL, -- 소유권 임대 만료(reaper 회수 기준) + worker_id VARCHAR(80) NULL, + run_started_at TIMESTAMPTZ NULL, + last_error TEXT NULL, + 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 place.place_aliases ( - alias_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 별칭 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) - alias VARCHAR(200) NOT NULL, -- 별칭(옛 상호, 본관/별관 표기 등) - source_type SMALLINT NOT NULL, -- 출처(SourceType): 1=owner 2=api 3=crawl 4=llm - 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 public.places ( + place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(users.user_id) — 회사(테넌트)를 걷어내고 이 컬럼이 그 자리를 받았다 + name VARCHAR(200) NOT NULL, -- 상호명(입력값) + category SMALLINT NOT NULL, -- PlaceCategory: 1=숙박 2=카페 3=음식점 4=피부과·성형외과 — 업종 스키마 선택 키 + status SMALLINT NOT NULL DEFAULT 1, -- PlaceStatus: 1=draft 2=collecting 3=review 4=published 5=suspended + external_source SMALLINT NULL, -- ExternalPlaceSource: 1=kakao 2=naver + external_place_id VARCHAR(64) NULL, -- 외부 고유 장소 id — ★ 카카오는 주고 네이버는 안 준다(그때는 상호명+도로명주소가 대체 키) + road_address VARCHAR(255) NULL, + address VARCHAR(255) NULL, -- 지번 + phone VARCHAR(30) NULL, + latitude NUMERIC(10,7) NULL, -- 빌드 시점 TourAPI 반경 조회가 읽는다 + longitude NUMERIC(10,7) NULL, + region_code VARCHAR(10) NULL, -- ★ 지역 콘텐츠 캐시 키. 같은 지역에 사이트 50개여도 외부 조회는 1회 + external_category VARCHAR(200) NULL, -- 외부 DB 분류 원문("음식점 > 한식 > 육류" · "펜션") — 주변 맛집에서 경쟁 업소를 빼는 기준(폴백) + verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다 + verified_by uuid NULL, + content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다 + 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 place.place_links ( - link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 링크 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) - channel SMALLINT NOT NULL, -- 채널(LinkChannel): 1=야놀자 2=여기어때 3=네이버플레이스 4=인스타 5=공식홈 6=블로그 7=네이버예약 99=기타 - url VARCHAR(1000) NOT NULL, -- 발견된 URL - title VARCHAR(300) NULL, -- 제목/스니펫 - discovered_by SMALLINT NOT NULL, -- 발견 주체(SourceType): 2=api(Perplexity) 1=owner(직접) - discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 발견 시각 - confirmed_at TIMESTAMPTZ NULL, -- ★ 동일 업소로 확정된 시각. NULL = 크롤링 대상 아님 - confirmed_by uuid NULL, -- 확정자(company.users.user_id) - raw JSONB NULL, -- Perplexity 응답 원문(본문 + search_results) — 환각 추적용, 사실 근거 아님 +-- Perplexity·네이버·TourAPI 가 발견한 채널 URL. +-- ★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 동일 업소임을 확인한 URL 만 넘긴다. +CREATE TABLE IF NOT EXISTS public.place_channels ( + link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + place_id uuid NOT NULL, + channel SMALLINT NOT NULL, -- LinkChannel: 1=야놀자 2=여기어때 3=네이버플레이스 4=인스타 5=공식홈 6=블로그 7=네이버예약 99=기타 + url VARCHAR(1000) NOT NULL, + title VARCHAR(300) NULL, + discovered_by SMALLINT NOT NULL, -- SourceType: 2=api(Perplexity) 1=owner(직접) + discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(), + confirmed_at TIMESTAMPTZ NULL, -- ★ NULL = 크롤링 금지 + confirmed_by uuid NULL, + raw JSONB NULL, -- Perplexity 응답 원문 — ★ 환각 추적용이고 사실 근거로 쓰지 않는다 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 place.units ( - unit_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 단위 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) - name VARCHAR(200) NOT NULL, -- 이름(객실명·메뉴명·프로그램명) - sort_order INTEGER NOT NULL DEFAULT 0, -- 노출 순서 +-- 업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과=프로그램. +-- 가변 필드는 place_facts(unit_id 있음)로 가고, 여기엔 목록 렌더에 필요한 뼈대만 둔다. +CREATE TABLE IF NOT EXISTS public.place_units ( + unit_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + place_id uuid NOT NULL, + name VARCHAR(200) NOT NULL, + 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 place.media ( - media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사진 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) - unit_id uuid NULL, -- 하위 단위(place.units.unit_id) - url VARCHAR(1000) NOT NULL, -- 우리가 보관하는 접근 URL - origin_url VARCHAR(1000) NULL, -- ★ 수집 원본 이미지 URL — 재게시 권리 판단용(DECISIONS 1-2) - source_type SMALLINT NOT NULL, -- 출처(SourceType): 1=owner 업로드 3=crawl 수집 - source_url VARCHAR(1000) NULL, -- 수집한 페이지 URL - label VARCHAR(200) NULL, -- Gemini Vision 분류 라벨 (예: "A동 침실") - alt_text VARCHAR(500) NULL, -- Vision 이 생성한 alt 텍스트 - vision_confidence NUMERIC(4,3) NULL, -- ★ 신뢰도 0.000~1.000. 낮으면 자동 반영 금지 - status SMALLINT NOT NULL DEFAULT 1, -- 상태(MediaStatus): 1=pending_review 2=approved 3=rejected +CREATE TABLE IF NOT EXISTS public.place_photos ( + media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + place_id uuid NOT NULL, + unit_id uuid NULL, -- 객실·메뉴 사진이면 연결 + url VARCHAR(1000) NOT NULL, -- 우리가 보관하는 접근 URL + origin_url VARCHAR(1000) NULL, -- ★ 수집 원본 URL — 이미지 재게시 권리가 미결이라(DECISIONS 1-2) 결론에 따라 걸러낼 수 있어야 한다 + source_type SMALLINT NOT NULL, -- SourceType: 1=owner 업로드 3=crawl 수집 + source_url VARCHAR(1000) NULL, -- 수집한 페이지 URL + label VARCHAR(200) NULL, -- Gemini Vision 분류 라벨 + alt_text VARCHAR(500) NULL, -- Vision 이 만든 alt + vision_confidence NUMERIC(4,3) NULL, -- ★ 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다 + status SMALLINT NOT NULL DEFAULT 1, -- MediaStatus: 1=pending_review 2=approved 3=rejected. ★ 2 만 사이트에 나간다 width INTEGER NULL, height INTEGER NULL, sort_order INTEGER NOT NULL DEFAULT 0, @@ -149,38 +187,36 @@ CREATE TABLE IF NOT EXISTS place.media ( deleted BOOLEAN NOT NULL DEFAULT FALSE ); --- ============================================================ --- fact : 사실 / FAQ --- ★ 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다. --- ★ VERIFIED(3) / CORRECTED(4) 만 사이트에 노출한다. --- ============================================================ -CREATE TABLE IF NOT EXISTS fact.facts ( - fact_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- fact 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) - unit_id uuid NULL, -- 하위 단위(place.units.unit_id). NULL = 사업장 단위 fact - key VARCHAR(100) NOT NULL, -- 업종 스키마(common/category_schema)에 정의된 key 만 허용 - value TEXT NULL, -- 값 - unit VARCHAR(30) NULL, -- 단위(원·명·분…) - source_type SMALLINT NOT NULL, -- 출처(SourceType): 1=owner 2=api 3=crawl 4=llm - source_url VARCHAR(1000) NULL, -- 출처 URL - collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수집 시각 - verified_by uuid NULL, -- 검증자(company.users.user_id) - verified_at TIMESTAMPTZ NULL, -- 검증 시각 - status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus): 1=unverified 2=pending_owner 3=verified 4=corrected 5=rejected 6=expired - expires_at TIMESTAMPTZ NULL, -- 유효기간. 지나면 EXPIRED 전이 대상 +-- ★ 가장 중요한 표. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다. +-- 출처 없는 사실은 이 제품의 규칙 위반이다. +CREATE TABLE IF NOT EXISTS public.place_facts ( + fact_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + place_id uuid NOT NULL, + unit_id uuid NULL, -- NULL = 사업장 단위 fact, 있으면 객실·메뉴 단위 + key VARCHAR(100) NOT NULL, -- ★ 업종 스키마(common/category_schema)에 정의된 key 만 허용된다 + value TEXT NULL, + unit VARCHAR(30) NULL, -- 값의 단위(원·명·분…) + source_type SMALLINT NOT NULL, -- SourceType: 1=owner 2=api 3=crawl 4=llm + source_url VARCHAR(1000) NULL, + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + verified_by uuid NULL, + verified_at TIMESTAMPTZ NULL, + status SMALLINT NOT NULL DEFAULT 1, -- FactStatus: 1=unverified 2=pending_owner 3=verified 4=corrected 5=rejected 6=expired + expires_at TIMESTAMPTZ NULL, -- 지나면 EXPIRED 전이 대상 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 fact.faqs ( - faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- FAQ 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) - question VARCHAR(500) NOT NULL, -- 질문 - answer TEXT NOT NULL, -- 답변 - source_fact_ids JSONB NULL, -- ★ 근거 fact_id 배열. 비면 발행 게이트가 반려 - generated_by SMALLINT NOT NULL, -- 작성 주체(SourceType): 4=llm 1=owner - status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus) +-- ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다. +CREATE TABLE IF NOT EXISTS public.place_faqs ( + faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + place_id uuid NOT NULL, + question VARCHAR(500) NOT NULL, + answer TEXT NOT NULL, + source_fact_ids JSONB NULL, -- ★ 근거 fact_id 배열. 비면 반려 + generated_by SMALLINT NOT NULL, -- SourceType: 4=llm 1=owner + 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(), @@ -188,362 +224,231 @@ CREATE TABLE IF NOT EXISTS fact.faqs ( ); -- ============================================================ --- local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변 --- ★ 캐시 키는 place_id 가 아니라 region_code — 같은 지역 사이트 50개여도 외부 조회는 1회. --- ★ 외부 API 실패 시 행을 지우거나 비우지 않는다. 직전 값을 유지하고 내부 알림만 낸다. +-- 지역 콘텐츠 — 접두어가 없는 유일한 표. **그게 공유물이라는 표시다.** +-- ★ 키는 place_id 가 아니다. 축제·관광지·맛집의 유일성은 출처가 준 external_id 이고, +-- 지역 이야기(가요·일력·인물·연표·엽서·퀴즈)의 유일성은 (region_code, kind) 다. +-- ★ 외부 API 가 실패해도 이 행을 지우거나 비우지 않는다 — 직전 값을 유지하고 알림만 낸다. -- ============================================================ -CREATE TABLE IF NOT EXISTS local.local_contents ( - local_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 지역 콘텐츠 식별자(PK) - region_code VARCHAR(10) NOT NULL, -- ★ 카카오 행정구역 코드 = 캐시 키 - content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 1=날씨 2=축제 3=관광지 4=맛집 - source SMALLINT NOT NULL, -- 출처(LocalSource): 1=Open-Meteo 2=TourAPI 3=카카오로컬 - external_id VARCHAR(100) NULL, -- 출처 고유 ID(TourAPI contentid 등). 날씨는 NULL - title VARCHAR(300) NULL, -- 제목 - body JSONB NOT NULL, -- 원문 페이로드(weathercode·기간·좌표 등) - status SMALLINT NOT NULL DEFAULT 1, -- 1=검수대기 2=발행 3=종료 - published_at TIMESTAMPTZ NULL, - published_by uuid NULL, -- 운영 관리자(company.users.user_id) - display_start_at TIMESTAMPTZ NULL, - display_end_at TIMESTAMPTZ NULL, - collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수집 시각 - expires_at TIMESTAMPTZ NULL, -- TTL. 지나면 갱신 대상(값은 유지) - 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 public.area_contents ( + local_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드. ★ NULL 허용 — 축제·관광지·맛집은 전국 공용이라 지역이 유일성의 근거가 아니다(0004) + content_type SMALLINT NOT NULL, -- LocalContentType: 1=날씨 2=축제 3=관광지 4=맛집 5=여행코스 6=지역이야기 + source SMALLINT NOT NULL, -- LocalSource: 1=Open-Meteo 2=TourAPI 3=카카오로컬 4=Perplexity + external_id VARCHAR(100) NULL, -- TourAPI contentid 등. 날씨·지역이야기는 NULL + title VARCHAR(300) NULL, + body JSONB NOT NULL, -- ★ 렌더러가 읽는 이름으로 저장한다(name·location·imageUrl). TourAPI 원문 이름으로 두면 빌드마다 같은 변환을 다시 한다(0008) + status SMALLINT NOT NULL DEFAULT 1, -- LocalContentStatus: 1=검수대기 2=발행 3=종료 + published_at TIMESTAMPTZ NULL, + published_by uuid NULL, + display_start_at TIMESTAMPTZ NULL, + display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다 + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NULL, -- TTL. 지나면 갱신 대상(값은 유지) + latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 뒀다 — 거리 계산이 행마다 JSON 을 펴지 않게(0004) + longitude NUMERIC(10,7) NULL, + kind VARCHAR(50) NULL, -- 'weather' 'festival' 'attraction' 'restaurant' 'course' / 이야기는 'songs' 'daily' 'people' 'chronicle' 'postcard' 'quiz' + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + 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) - origin_name VARCHAR(200) NOT NULL, -- 출발지 (예: "서울역") - transport SMALLINT NOT NULL, -- 수단(TransportType): 1=자동차 2=대중교통 3=도보 - duration_min INTEGER NULL, -- 소요 시간(분) - distance_m INTEGER NULL, -- 거리(m) - description TEXT NULL, -- 경로 설명 - source_type SMALLINT NOT NULL, -- 출처(SourceType) - status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus) - 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 local.nearby_links ( - nearby_link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 주변 항목 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) - region_code VARCHAR(10) NULL, -- 어느 지역 캐시에서 파생됐는지 - name VARCHAR(200) NOT NULL, -- 상호명 - category_name VARCHAR(100) NULL, -- 카카오 category_name - kakao_place_id VARCHAR(32) NULL, -- 카카오 장소 ID - distance_m INTEGER NULL, -- 거리(m) - url VARCHAR(1000) NULL, -- 링크 - latitude NUMERIC(10,7) NULL, - longitude NUMERIC(10,7) NULL, - 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 +-- 업장 ↔ 지역 콘텐츠. **업장별로 다른 것은 거리와 숨김뿐이다.** +-- ★ 예전에는 이 표가 값을 통째로 들고 있었다. 키가 place_id 라서 업장마다 TourAPI 응답이 +-- 복제됐다 — 실측(2026-09-09) 한 곳에 144행이고, 같은 동네에 모텔이 열 곳 들어오면 +-- 같은 축제가 열 벌이 된다. 실체는 area_contents 에 한 행, 여기엔 관계만 남긴다. +CREATE TABLE IF NOT EXISTS public.place_area_refs ( + place_id uuid NOT NULL, + local_content_id uuid NOT NULL, + 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) ); -- ============================================================ --- site : 사이트 / 버전 / 발행 로그 / AI 노출 점검 +-- 사이트 -- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다. --- ★ 개별 재빌드 단위. 해지는 삭제가 아니라 상태 전이. +-- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다. -- ============================================================ -CREATE TABLE IF NOT EXISTS site.sites ( - site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사이트 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) — 1:1 - domain VARCHAR(255) NULL, -- 발행 도메인 - path_prefix VARCHAR(100) NULL, -- 경로 프리픽스 - template_id VARCHAR(100) NULL, -- 사장님이 고른 템플릿 키(프론트 배리에이션 레지스트리 id). NULL 이면 업종 기본 템플릿으로 굽는다 - theme JSONB NULL, -- ★ 에디터가 정한 색·서체·섹션. {"colors":{},"fontStyle":"","sections":[{"id","name","enabled","locked","variantId"}]}. NULL 이면 업종 기본으로 굽는다. templateId 는 위 컬럼이 소유한다(중복 보관 금지) - status SMALLINT NOT NULL DEFAULT 1, -- 상태(SiteStatus): 1=draft 2=review 3=published 4=suspended 5=unpublished - current_version_id uuid NULL, -- 현재 발행 버전(site.site_versions.site_version_id) - published_at TIMESTAMPTZ NULL, -- 최초/최근 발행 시각 - thumbnail_url VARCHAR(500) NULL, -- 발행 썸네일(Azure Blob 공개 URL). 발행에 성공한 뒤에만 채워진다 — 쇼케이스·사이트 목록 카드가 읽는다 +CREATE TABLE IF NOT EXISTS public.sites ( + site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + place_id uuid NOT NULL, -- 사업장과 1:1 + domain VARCHAR(255) NULL, + path_prefix VARCHAR(100) NULL, + template_id VARCHAR(100) NULL, -- 사장님이 고른 템플릿 키. ★ 서버는 해석하지 않고 보관·반환만 한다 — 목록은 프론트가 소유한다 + theme JSONB NULL, -- ★ 색·서체·섹션 순서/on-off/배리에이션. 내용은 site_sections 로 나갔다. templateId 는 위 컬럼이 소유한다(중복 보관 금지) + status SMALLINT NOT NULL DEFAULT 1, -- SiteStatus: 1=draft 2=review 3=published 4=suspended 5=unpublished + current_version_id uuid NULL, -- site_versions.site_version_id + published_at TIMESTAMPTZ NULL, + thumbnail_url VARCHAR(500) NULL, -- ★ 발행에 성공한 뒤에만 채운다. 스크린샷이 아니라 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE ); +COMMENT ON COLUMN public.sites.template_id IS '사장님이 고른 템플릿 키. NULL 이면 업종 기본 템플릿으로 굽는다.'; +COMMENT ON COLUMN public.sites.theme IS '에디터가 정한 디자인. {"colors":{...},"fontStyle":"...","sections":[{"id","name","enabled","locked","variantId"}]} — 서버는 해석하지 않고 그대로 보관·반환한다(목록은 프론트가 소유). NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다. templateId 는 sites.template_id 가 소유한다.'; + -- 섹션 하나의 콘텐츠. ★ **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 만 노출 +-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%). +-- 크기가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도 42 KB 를 다시 쓰고, +-- 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다 "누가 넣었나·확인됐나"를 물을 자리가 없었다. +CREATE TABLE IF NOT EXISTS public.site_sections ( + site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + site_id uuid NOT NULL, + section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' 'local' … + data JSONB NOT NULL, -- shared 의 XxxItem[] 계약. ★ section_id='local' 은 배열이 아니라 ref→값 **맵**이다 + source_type SMALLINT NOT NULL DEFAULT 1, -- SourceType: 1=owner 2=api 4=llm + shared_ref uuid NULL, -- ★ 공용 원본(area_contents)을 가리킬 때. 값을 복제하지 않고 발행할 때 펼친다 + status SMALLINT NOT NULL DEFAULT 1, -- FactStatus 와 같은 축 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) - version INTEGER NOT NULL, -- 버전 번호(1부터) - build_status SMALLINT NOT NULL DEFAULT 1, -- 빌드상태(BuildStatus): 1=pending 2=building 3=built 4=failed - snapshot JSONB NULL, -- ★ 빌드 시점 데이터 박제(정적 빌드의 입력) - jsonld JSONB NULL, -- ★ 구조화 데이터. 화면 값과 불일치하면 빌드 실패 - unique_content_count INTEGER NOT NULL DEFAULT 0, -- ★ 0 이면 발행 API 가 거부(스팸 판정 대상) - build_error TEXT NULL, -- 빌드 실패 사유 - built_at TIMESTAMPTZ NULL, -- 빌드 완료 시각 +CREATE TABLE IF NOT EXISTS public.site_versions ( + site_version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + site_id uuid NOT NULL, + version INTEGER NOT NULL, -- 1부터 + build_status SMALLINT NOT NULL DEFAULT 1, -- BuildStatus: 1=pending 2=building 3=built 4=failed + snapshot JSONB NULL, -- ★ 빌드 시점 데이터 박제. 이게 정적 빌드의 경계이자 감사 기록이다 + jsonld JSONB NULL, -- ★ 렌더러가 **실제로 내보낸** 값. 백엔드가 따로 계산하지 않는다 — 따로 계산하면 게이트의 근거와 실제 페이지가 어긋난다 + unique_content_count INTEGER NOT NULL DEFAULT 0, -- ★ 0 이면 발행 거부(스팸 판정 대상) + build_error TEXT NULL, + built_at TIMESTAMPTZ NULL, -- places.content_updated_at 과 비교해 재빌드 대상을 고른다 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.publish_logs ( - publish_log_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 발행 로그 식별자(PK) - site_id uuid NOT NULL, -- 사이트(site.sites.site_id) - site_version_id uuid NULL, -- 버전(site.site_versions.site_version_id) - action SMALLINT NOT NULL, -- 동작(PublishAction): 1=publish 2=unpublish 3=rebuild 4=suspend 5=resume - result SMALLINT NOT NULL, -- 결과(PublishResult): 1=success 2=rejected 3=failed - reject_reason SMALLINT NULL, -- 거부 사유(PublishRejectReason): 1=미검증fact 2=고유콘텐츠없음 3=JSON-LD불일치 4=필수fact누락 - detail JSONB NULL, -- 막힌 항목 목록(미검증 fact key 등) - actor_user_id uuid NULL, -- 실행자(company.users.user_id) +-- 발행 시도 기록. ★ site_versions 와 1:1 이 아니다 — 같은 버전을 다시 굽거나 내리면 2:1 이 된다. +-- site_versions 는 "무엇을 구웠나", 여기는 "언제 무슨 일이 있었나"다. +CREATE TABLE IF NOT EXISTS public.site_publish_logs ( + publish_log_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + site_id uuid NOT NULL, + site_version_id uuid NULL, + action SMALLINT NOT NULL, -- PublishAction: 1=publish 2=unpublish 3=rebuild 4=suspend 5=resume + result SMALLINT NOT NULL, -- PublishResult: 1=success 2=rejected 3=failed + reject_reason SMALLINT NULL, -- PublishRejectReason: 1=미검증fact 2=고유콘텐츠없음 3=JSON-LD불일치 4=필수fact누락 + detail JSONB NULL, -- ★ 막힌 항목 목록. 화면의 반려 카드가 이걸로 "무엇을 고쳐야 하는지"를 말한다 + actor_user_id uuid NULL, 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.ai_check_results ( - ai_check_result_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 점검 결과 식별자(PK) - place_id uuid NOT NULL, -- 사업장(place.places.place_id) - engine SMALLINT NOT NULL, -- 엔진(AiEngine): 1=ChatGPT 2=Perplexity 3=Gemini 4=Claude 99=기타 - query VARCHAR(500) NOT NULL, -- 던진 질의 - answer TEXT NULL, -- 받은 답변 - cited_urls JSONB NULL, -- 인용된 URL 목록 - is_own_site_cited BOOLEAN NOT NULL DEFAULT FALSE, -- ★ 핵심 지표 — 우리 사이트를 근거로 답했는가 - ota_cited BOOLEAN NOT NULL DEFAULT FALSE, -- OTA(야놀자·여기어때)가 대신 인용됐는가 - checked_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 -); - - --- ============================================================ --- job : 작업 큐 (PostgreSQL 을 큐로 — 원자적 CAS claim + lease 소유권 + dead-letter) --- 수집·비전분석·빌드는 몇 분 걸린다. 동기 요청으로 처리하지 않는다. --- 워커 컨테이너가 죽어도 lease 가 만료되면 reaper 가 잡을 회수한다. --- ============================================================ -CREATE TABLE IF NOT EXISTS job.jobs ( - job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 잡 식별자(PK) - job_type SMALLINT NOT NULL, -- 종류(JobType): 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check - status SMALLINT NOT NULL DEFAULT 1, -- 상태(JobStatus): 1=pending 2=running 3=done 4=dead - priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선 - payload JSONB NOT NULL DEFAULT '{}'::jsonb, -- 잡 입력 - result JSONB NULL, -- 잡 출력(완료 시) - dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크) - attempts SMALLINT NOT NULL DEFAULT 0, -- 시도 횟수(claim 시 +1) - max_attempts SMALLINT NOT NULL DEFAULT 3, -- 소진되면 DEAD - run_after TIMESTAMPTZ NOT NULL DEFAULT now(), -- 이 시각 이후에만 claim(백오프) - lease_until TIMESTAMPTZ NULL, -- 소유권 임대 만료(reaper 회수 기준) - worker_id VARCHAR(80) NULL, -- 현재 점유 워커 - run_started_at TIMESTAMPTZ NULL, -- RUNNING 진입 시각 - last_error TEXT NULL, -- 마지막 실패 사유 - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - deleted BOOLEAN NOT NULL DEFAULT FALSE -- 공통 규약(잡은 이력이라 실제로는 쓰지 않음) -); - -- ============================================================ -- 인덱스 +-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. -- ============================================================ --- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. -CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; +-- users +CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON public.users (id) WHERE deleted = FALSE; +CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON public.users (provider, provider_uid) + WHERE deleted = FALSE AND provider_uid IS NOT NULL; --- place -CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON place.places (owner_user_id); -CREATE INDEX IF NOT EXISTS idx_places_region_code ON place.places (region_code) WHERE deleted = FALSE; -CREATE INDEX IF NOT EXISTS idx_place_aliases_place ON place.place_aliases (place_id); -CREATE INDEX IF NOT EXISTS idx_place_links_place ON place.place_links (place_id); -CREATE INDEX IF NOT EXISTS idx_units_place ON place.units (place_id); -CREATE INDEX IF NOT EXISTS idx_media_place ON place.media (place_id); -CREATE INDEX IF NOT EXISTS idx_media_unit ON place.media (unit_id); - -CREATE INDEX IF NOT EXISTS idx_places_external ON place.places (external_source, external_place_id) +-- places +CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON public.places (owner_user_id); +CREATE INDEX IF NOT EXISTS idx_places_region_code ON public.places (region_code) WHERE deleted = FALSE; +CREATE INDEX IF NOT EXISTS idx_places_external ON public.places (external_source, external_place_id) WHERE deleted = FALSE AND external_place_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS uq_place_links_place_url ON place.place_links (place_id, url) + +CREATE INDEX IF NOT EXISTS idx_place_links_place ON public.place_channels (place_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_place_links_place_url ON public.place_channels (place_id, url) WHERE deleted = FALSE; --- fact -CREATE INDEX IF NOT EXISTS idx_facts_place ON fact.facts (place_id); -CREATE INDEX IF NOT EXISTS idx_facts_unit ON fact.facts (unit_id); -CREATE INDEX IF NOT EXISTS idx_faqs_place ON fact.faqs (place_id); +CREATE INDEX IF NOT EXISTS idx_units_place ON public.place_units (place_id); +CREATE INDEX IF NOT EXISTS idx_media_place ON public.place_photos (place_id); +CREATE INDEX IF NOT EXISTS idx_media_unit ON public.place_photos (unit_id); + +-- place_facts +CREATE INDEX IF NOT EXISTS idx_facts_place ON public.place_facts (place_id); +CREATE INDEX IF NOT EXISTS idx_facts_unit ON public.place_facts (unit_id); +CREATE INDEX IF NOT EXISTS idx_faqs_place ON public.place_faqs (place_id); -- ★ 사이트에 나가는 값(3=VERIFIED, 4=CORRECTED)은 (사업장, 단위, key) 당 1건. --- 후보(1=UNVERIFIED, 2=PENDING_OWNER)는 여러 건 공존한다 — 재수집이 노출값을 밀어내지 않고 쌓이게 하려는 것. +-- 후보(1·2)는 여러 건 공존한다 — 재수집이 노출값을 밀어내지 않고 쌓이게 하려는 것이다. -- 유니크를 활성 전체에 걸면 재수집 때마다 확인된 값이 사이트에서 사라진다. -- unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 사업장 단위 / 하위 단위를 나눠 건다. -CREATE UNIQUE INDEX IF NOT EXISTS uq_facts_published_place_key ON fact.facts (place_id, key) +CREATE UNIQUE INDEX IF NOT EXISTS uq_facts_published_place_key ON public.place_facts (place_id, key) WHERE deleted = FALSE AND unit_id IS NULL AND status IN (3, 4); -CREATE UNIQUE INDEX IF NOT EXISTS uq_facts_published_unit_key ON fact.facts (place_id, unit_id, key) +CREATE UNIQUE INDEX IF NOT EXISTS uq_facts_published_unit_key ON public.place_facts (place_id, unit_id, key) WHERE deleted = FALSE AND unit_id IS NOT NULL AND status IN (3, 4); -- 후보 조회 경로(사람 확인 큐). -CREATE INDEX IF NOT EXISTS idx_facts_candidate ON fact.facts (place_id, key, source_type) +CREATE INDEX IF NOT EXISTS idx_facts_candidate ON public.place_facts (place_id, key, source_type) WHERE deleted = FALSE AND status IN (1, 2); --- 발행 게이트가 "노출 가능한 fact"(VERIFIED·CORRECTED) 만 훑는 경로. -CREATE INDEX IF NOT EXISTS idx_facts_publishable ON fact.facts (place_id, status) +-- 발행 게이트가 "노출 가능한 fact" 만 훑는 경로. +CREATE INDEX IF NOT EXISTS idx_facts_publishable ON public.place_facts (place_id, status) WHERE deleted = FALSE AND status IN (3, 4); --- 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; +-- area_contents +CREATE INDEX IF NOT EXISTS idx_local_contents_region ON public.area_contents (region_code); +CREATE INDEX IF NOT EXISTS idx_local_contents_status ON public.area_contents (status, collected_at DESC) + WHERE deleted = FALSE; +CREATE INDEX IF NOT EXISTS idx_local_contents_type_region ON public.area_contents (content_type, region_code) + 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 AND kind IS NULL; +-- ★ 유일성의 근거가 셋이고 서로 겹치면 안 된다. 실제로 겹쳐서 조용히 틀린 적이 있다 — +-- 지역 이야기 다섯 종이 (region_code, content_type=6) 하나를 두고 부딪쳐 **첫 종류만 +-- 저장되고 잡은 "성공" 으로 끝났다**(실측 2026-09-09, 52군산시: 생성 54건 · 저장 1종류). +-- 그래서 조건에 external_id / kind 의 유무를 넣어 세 인덱스가 각자 자기 몫만 보게 한다. +CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_external ON public.area_contents (source, external_id) + WHERE deleted = FALSE AND external_id IS NOT NULL; -- 축제·관광지·맛집: 출처 id 하나면 한 행(지역 무관) +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; -- 지역 이야기: 지역 × 종류 한 벌 +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; -- 날씨: 지역 × 종류 한 행 --- site -CREATE INDEX IF NOT EXISTS idx_site_versions_site ON site.site_versions (site_id); -CREATE INDEX IF NOT EXISTS idx_publish_logs_site ON site.publish_logs (site_id); -CREATE INDEX IF NOT EXISTS idx_ai_check_place ON site.ai_check_results (place_id); +CREATE INDEX IF NOT EXISTS idx_place_contents_place ON public.place_area_refs (place_id) WHERE deleted = FALSE; -CREATE UNIQUE INDEX IF NOT EXISTS uq_sites_place ON site.sites (place_id) WHERE deleted = FALSE; -CREATE UNIQUE INDEX IF NOT EXISTS uq_sites_domain ON site.sites (domain) WHERE deleted = FALSE AND domain IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS uq_site_versions_no ON site.site_versions (site_id, version) WHERE deleted = FALSE; +-- sites +CREATE UNIQUE INDEX IF NOT EXISTS uq_sites_place ON public.sites (place_id) WHERE deleted = FALSE; +CREATE UNIQUE INDEX IF NOT EXISTS uq_sites_domain ON public.sites (domain) + WHERE deleted = FALSE AND domain IS NOT NULL; --- job (작업 큐) +CREATE INDEX IF NOT EXISTS idx_site_contents_site ON public.site_sections (site_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section ON public.site_sections (site_id, section_id) + WHERE deleted = FALSE; + +CREATE INDEX IF NOT EXISTS idx_site_versions_site ON public.site_versions (site_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_site_versions_no ON public.site_versions (site_id, version) + WHERE deleted = FALSE; + +CREATE INDEX IF NOT EXISTS idx_publish_logs_site ON public.site_publish_logs (site_id); + +-- jobs -- claim 경로: PENDING 이면서 run_after 가 지난 잡을 priority·created_at 순으로. -CREATE INDEX IF NOT EXISTS ix_jobs_claim ON job.jobs (status, run_after, priority, created_at); +CREATE INDEX IF NOT EXISTS ix_jobs_claim ON public.jobs (status, run_after, priority, created_at); -- reaper 경로: 만료된 lease 회수. -CREATE INDEX IF NOT EXISTS ix_jobs_lease ON job.jobs (status, lease_until); --- 활성 중복 방지: 같은 dedupe_key 는 PENDING(1)/RUNNING(2) 중 하나만 존재 가능. -CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON job.jobs (dedupe_key) +CREATE INDEX IF NOT EXISTS ix_jobs_lease ON public.jobs (status, lease_until); +-- 활성 중복 방지: 같은 dedupe_key 는 PENDING(1)/RUNNING(2) 중 하나만. +CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON public.jobs (dedupe_key) WHERE status IN (1, 2) AND dedupe_key IS NOT NULL; -- ============================================================ --- 기존 DB 보정(ALTER) +-- 마이그레이션 기준선(baseline) -- ============================================================ --- 위 CREATE TABLE IF NOT EXISTS 는 기존 테이블을 바꾸지 못하므로, 컬럼 추가/타입 변경은 여기에 누적한다. -ALTER TABLE place.places - ADD COLUMN IF NOT EXISTS content_updated_at TIMESTAMPTZ NULL, -- 2026-08-27 노출값 변경 시각(개별 재빌드 판별) - ADD COLUMN IF NOT EXISTS external_source SMALLINT NULL; -- 2026-08-27 검증 소스(kakao/naver) +-- ★ 위 DDL 은 0008 까지 적용된 모습이다. 그 사실을 기록해 두지 않으면, 새로 세운 DB 에서 +-- `migrate.py` 가 0001 부터 다시 돌리려다 **`schema "local" does not exist` 로 죽는다** — +-- 그 파일들은 옛 스키마를 전제로 쓰였기 때문이다. +-- ★ 마이그레이션을 새로 더하고 그 내용을 이 파일에 반영했다면, 그 번호를 여기에도 넣는다. +-- 빠뜨려도 치명적이진 않다(파일들은 재실행 안전하게 쓴다) — 다만 새 DB 에서 한 번 더 돈다. +CREATE TABLE IF NOT EXISTS public.schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); --- 2026-09-02 구글 로그인. 소셜 계정은 비밀번호가 없고(NULL), 로그인 아이디가 google_ 라 20자를 넘는다. -ALTER TABLE company.users - ADD COLUMN IF NOT EXISTS provider SMALLINT NOT NULL DEFAULT 1, - ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL; -ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64); -ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; --- ★ 이 인덱스는 위 인덱스 절이 아니라 **여기** 있어야 한다. 기존 DB 에서는 컬럼이 ALTER 로 --- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다. -CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) - WHERE deleted = FALSE AND provider_uid IS NOT NULL; - --- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다 --- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다). -ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL; - --- 2026-09-08 회사(테넌트) 제거. 쓰는 사람은 사장님 혼자인데 가입 한 번이 회사를 하나 만들고 --- 그 회사의 직원이 되는 구조였다. 사업장을 계정에 직접 매단다. --- ★ 순서가 중요하다 — 백필 → NOT NULL → 컬럼 삭제. 반대로 하면 주인을 잃은 행이 남는다. --- ★ 회사에 계정이 여럿이던 경우(내부 운영 계정)는 **가장 먼저 만든 계정**에게 몰아준다. -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema='place' AND table_name='places' AND column_name='company_id') THEN - UPDATE place.places p - SET owner_user_id = ( - SELECT u.user_id FROM company.users u - WHERE u.company_id = p.company_id AND u.deleted = FALSE - ORDER BY u.created_at LIMIT 1) - WHERE p.owner_user_id IS NULL; - -- 주인을 못 찾은 행(회사가 통째로 지워진 경우)은 남겨 둘 수 없다 — 스코프가 없으면 아무에게도 안 보인다. - DELETE FROM place.places WHERE owner_user_id IS NULL; - ALTER TABLE place.places ALTER COLUMN owner_user_id SET NOT NULL; - ALTER TABLE place.places DROP COLUMN company_id; - END IF; -END $$; -ALTER TABLE company.users DROP COLUMN IF EXISTS company_id; -DROP TABLE IF EXISTS company.companies; +INSERT INTO public.schema_migrations (version) VALUES + ('0001_place_contents_external_category'), + ('0002_spots_shared'), + ('0003_site_contents'), + ('0004_local_contents_unify'), + ('0005_flatten_schemas'), + ('0006_prune_unused'), + ('0007_story_rows_per_kind'), + ('0008_personalization_to_site_sections'), + ('0009_align_with_init_sql') +ON CONFLICT (version) DO NOTHING; diff --git a/postgres-init/migrations/0009_align_with_init_sql.sql b/postgres-init/migrations/0009_align_with_init_sql.sql new file mode 100644 index 0000000..c6c656b --- /dev/null +++ b/postgres-init/migrations/0009_align_with_init_sql.sql @@ -0,0 +1,43 @@ +-- 0009 · init.sql 을 현재 스키마로 다시 쓰면서 드러난 어긋남을 맞춘다 +-- +-- ★ 어떻게 찾았나 (2026-09-10) +-- `init.sql` 이 0005 의 스키마 해체를 따라오지 않아 옛 도메인 스키마를 그대로 만들고 있었다. +-- 그걸 현재 모습으로 다시 쓴 뒤, **빈 컨테이너에 새로 세운 DB** 와 **마이그레이션으로 따라온 +-- 기존 DB** 를 `pg_dump --schema-only` 로 나란히 놓고 비교했다. 표 목록은 같았고 아래 셋이 달랐다. +-- 이 비교는 앞으로도 같은 방법으로 한다 — ORM 주석이나 기억이 아니라 두 DB 를 실제로 찍어 본다. +-- +-- ★ 이 파일은 데이터를 건드리지 않는다. 이름·타입·인덱스만 맞춘다. + +-- ── 1. 없는 인덱스 (실사용에 영향) ────────────────────────────────────── +-- 0003 이 기존 DB 에 site_contents 를 만들 때 유니크(uq_site_contents_section)만 걸고 +-- place 조회용 인덱스를 빠뜨렸다. init.sql 에는 처음부터 있었으므로 **새 DB 에만 있고 +-- 기존 DB 에는 없는** 상태였다 — 사이트 하나의 섹션을 읽을 때마다 시퀀셜 스캔이다. +CREATE INDEX IF NOT EXISTS idx_site_contents_site ON public.site_sections (site_id); + +-- ── 2. 컬럼 폭 (ORM 과 불일치) ────────────────────────────────────────── +-- ORM 은 String(64) 인데 DB 는 VARCHAR(32) 였다. 지금 쓰는 카카오 place id 는 8~10자라 +-- 아직 안 터졌지만, 다른 출처(네이버·TourAPI)의 id 를 넣는 날 잘려 들어간다 — +-- 잘린 id 는 "동일 업소 판정" 을 조용히 틀리게 만드는 종류다. +ALTER TABLE public.places ALTER COLUMN external_place_id TYPE VARCHAR(64); + +-- ── 3. 옛 이름이 남은 PK 제약 ─────────────────────────────────────────── +-- `ALTER TABLE ... RENAME TO` 는 제약 이름을 따라 바꾸지 않는다. 그래서 0005 이후로 +-- `place_facts` 의 PK 가 `facts_pkey` 로 남아 있었다. 동작에는 영향이 없지만, +-- 스키마를 덤프해 비교할 때마다 "새 DB 와 기존 DB 가 다르다" 로 보인다 — +-- 진짜 차이를 찾는 눈을 가리는 잡음이라 지금 맞춰 둔다. +ALTER INDEX IF EXISTS public.facts_pkey RENAME TO place_facts_pkey; +ALTER INDEX IF EXISTS public.faqs_pkey RENAME TO place_faqs_pkey; +ALTER INDEX IF EXISTS public.media_pkey RENAME TO place_photos_pkey; +ALTER INDEX IF EXISTS public.units_pkey RENAME TO place_units_pkey; +ALTER INDEX IF EXISTS public.place_links_pkey RENAME TO place_channels_pkey; +ALTER INDEX IF EXISTS public.local_contents_pkey RENAME TO area_contents_pkey; +ALTER INDEX IF EXISTS public.place_contents_pkey RENAME TO place_area_refs_pkey; +ALTER INDEX IF EXISTS public.site_contents_pkey RENAME TO site_sections_pkey; +ALTER INDEX IF EXISTS public.publish_logs_pkey RENAME TO site_publish_logs_pkey; +-- 0004 가 place_contents 를 새로 만들면서 붙은 번호(_pkey1). 위 이름이 이미 비어 있으면 +-- 이쪽이 진짜 PK 다. +ALTER INDEX IF EXISTS public.place_contents_pkey1 RENAME TO place_area_refs_pkey; + +-- ★ 인덱스 이름은 바꾸지 않는다(idx_facts_place · uq_place_links_place_url …). +-- ORM 의 __table_args__ 가 그 이름을 들고 있어서, 여기서 바꾸면 ORM 도 같이 고쳐야 하고 +-- 그 사이에 같은 인덱스가 두 벌 생긴다. 제약 이름과 달리 이건 코드가 참조한다. diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index fcfb8a5..5a57610 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -265,28 +265,46 @@ class area_contents(MainTableMixin, MAIN_BASE): ★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다.""" __tablename__ = "area_contents" + # ★ 유일성의 근거가 셋이고 **서로 겹치면 안 된다.** 겹쳐서 조용히 틀린 적이 있다 — + # 지역 이야기 다섯 종이 (region_code, content_type=6) 하나를 두고 부딪쳐 **첫 종류만 + # 저장되고 잡은 "성공" 으로 끝났다**(실측 2026-09-09, 52군산시: 생성 54건 · 저장 1종류). + # 그래서 조건에 external_id / kind 의 유무를 넣어 셋이 각자 자기 몫만 보게 가른다. + # ★ 이 세 정의는 init.sql · migrations(0004·0007·0008) 과 **같아야 한다.** 테스트 DB 는 + # 이 모델로 세워지므로, 어긋나면 테스트가 운영과 다른 제약 아래에서 돈다 — + # 실제로 그랬다: 여기만 옛 정의로 남아, 운영 DB 가 허용하는 행을 테스트가 거부했다. __table_args__ = ( - # external_id 가 있는 항목(축제·관광지·맛집)은 출처 고유 ID 로 중복을 막는다. + # 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. **지역과 무관하다** — + # 같은 축제가 시군구마다 한 행씩 생기면 "공용 한 벌" 이 아니다(0004). Index( - "uq_local_contents_keyed", - "region_code", - "content_type", + "uq_local_contents_external", + "source", "external_id", unique=True, postgresql_where=text("deleted = false AND external_id IS NOT NULL"), ), - # external_id 가 없는 항목(날씨)은 지역 × 종류당 1행. + # 지역 이야기: 한 지역에 종류당 한 벌. + Index( + "uq_local_contents_kind", + "region_code", + "kind", + unique=True, + postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"), + ), + # 날씨: 지역 × 종류당 한 행. kind 가 있는 행은 위가 책임지므로 여기서 뺀다(0007). Index( "uq_local_contents_single", "region_code", "content_type", unique=True, - postgresql_where=text("deleted = false AND external_id IS NULL"), + postgresql_where=text("deleted = false AND external_id IS NULL AND kind IS NULL"), ), ) local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - region_code = Column(String(10), nullable=False, index=True) # 카카오 행정구역 코드 = 캐시 키 + # ★ nullable 이다. 축제·관광지·맛집은 **전국 공용**이라 지역이 유일성의 근거가 아니다 — + # 같은 축제가 시군구마다 한 행씩 생기면 "한 벌" 이 아니다(migrations/0004). + # 지역 이야기·날씨만 이 값을 키로 쓴다. + region_code = Column(String(10), nullable=True, index=True) # 카카오 행정구역 코드 content_type = Column(SmallInteger, nullable=False) # LocalContentType source = Column(SmallInteger, nullable=False) # LocalSource external_id = Column(String(100), nullable=True) # TourAPI contentid 등 출처 고유 ID diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index ec606ed..3e66d99 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -18,10 +18,17 @@ from common.enums import UserRole, UserStatus from config.server_configs import main_db_config -# 모델이 쓰는 스키마. test DB 는 비어 있을 수 있어 create_all 전에 직접 만든다. -# 또 TRUNCATE 가 unqualified 테이블명을 쓰므로 이 스키마들을 search_path 에 얹어 해석시킨다. -# 도메인 모듈(places/facts/sites/local/reports)이 붙을 때마다 스키마를 여기에 추가한다. -_SCHEMAS = ("company", "place", "fact", "local", "site", "job") +# ★ 스키마는 public 한 벌이다 — 도메인 스키마(company·place·fact·local·site·job)는 +# 2026-09-09 에 걷어냈다(migrations/0005). 그래서 여기서 스키마를 만들지도, search_path 를 +# 얹지도 않는다. +# +# ★ 비울 표는 **ORM 이 아는 것**에서 뽑는다. 예전에는 이름을 손으로 나열했는데, +# 0005 가 표 이름을 옮겼을 때 이 문자열만 옛 이름으로 남아 테스트 13건이 통째로 +# `relation "place_aliases" does not exist` 로 죽었다 — 문자열이라 import 도 타입검사도 +# pyflakes 도 잡지 못한다. 모델에서 뽑으면 다시 어긋날 수 없다. +def _truncate_sql() -> str: + names = ", ".join(t.name for t in MAIN_BASE.metadata.sorted_tables) + return f"TRUNCATE TABLE {names} RESTART IDENTITY CASCADE" def _write_url(cfg) -> str: @@ -81,22 +88,11 @@ async def db_engine(_test_db_lifecycle): f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. " "APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다." ) - engine = create_async_engine( - _write_url(main_db_config), - connect_args={"server_settings": {"search_path": ",".join(_SCHEMAS) + ",public"}}, - ) + engine = create_async_engine(_write_url(main_db_config)) async with engine.begin() as conn: - for sch in _SCHEMAS: - await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {sch}")) await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip # 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망) - await conn.execute( - text( - "TRUNCATE TABLE users, places, place_aliases, place_links, units, media, " - "facts, faqs, local_contents, routes, nearby_links, " - "sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE" - ) - ) + await conn.execute(text(_truncate_sql())) yield engine await engine.dispose() diff --git a/solution/backend/crud/local_content_crud.py b/solution/backend/crud/local_content_crud.py index 9d06460..4c0a717 100644 --- a/solution/backend/crud/local_content_crud.py +++ b/solution/backend/crud/local_content_crud.py @@ -42,40 +42,6 @@ class LocalContentCRUD: 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): """지역 이야기 한 종류(가요·인물·…)의 삽입/갱신. @@ -88,7 +54,14 @@ class LocalContentCRUD: 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 + # ★ 조건은 인덱스와 **글자 그대로** 같아야 한다. 포스트그레스는 ON CONFLICT 술어가 + # 인덱스 술어를 함의하는지 보고, 아니면 "no unique or exclusion constraint matching" + # 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보이는 종류다. + index_where=and_( + area_contents.deleted == False, # noqa: E712 + area_contents.kind.isnot(None), + area_contents.external_id.is_(None), + ), set_={ "title": stmt.excluded.title, "body": stmt.excluded.body, @@ -129,7 +102,13 @@ class LocalContentCRUD: stmt = pg_insert(area_contents).values(**values) stmt = stmt.on_conflict_do_update( 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 + # ★ `kind IS NULL` 이 빠져 있어 이 upsert 가 통째로 실패하고 있었다(0007 이 인덱스에 + # 그 조건을 더했다). 날씨는 캐시라 실패해도 화면이 안 죽어서 **로그에만 남았다.** + index_where=and_( + area_contents.deleted == False, # noqa: E712 + area_contents.external_id.is_(None), + area_contents.kind.is_(None), + ), set_={ "source": stmt.excluded.source, "body": stmt.excluded.body, diff --git a/solution/backend/services/external/kakao.py b/solution/backend/services/external/kakao.py index 5fcde48..c581890 100644 --- a/solution/backend/services/external/kakao.py +++ b/solution/backend/services/external/kakao.py @@ -22,7 +22,7 @@ - 좌표 변환(coord_to_region) : 싸다. 사업장당 1회면 충분(좌표는 안 바뀐다) - 카테고리 검색(search_category): 비싸다. ★ **행정구역 코드 단위로 캐싱**해야 한다. 같은 지역에 사이트가 50개 생겨도 조회는 1회여야 한다. - 캐싱 자체는 local 모듈(local.local_contents, 캐시 키 = region_code)이 책임진다 — + 캐싱 자체는 지역 모듈(area_contents, 캐시 키 = region_code)이 책임진다 — 이 클라이언트는 캐시를 두지 않는다. 호출 전에 캐시를 먼저 보라는 뜻이다. 호출 횟수는 전부 LOG.i 로 남긴다(비용 추적). _CALL_COUNTS 로 프로세스 누적도 볼 수 있다. @@ -400,7 +400,7 @@ class KakaoLocalClient: ★ 비싸다(초과 시 건당 2원 — 좌표 변환의 4배). **반드시 행정구역 코드 단위로 캐싱해서 부른다.** 같은 지역에 사이트가 50개 생겨도 이 호출은 1회여야 한다. - 캐싱은 이 클라이언트가 하지 않는다 — local 모듈이 local.local_contents 에 + 캐싱은 이 클라이언트가 하지 않는다 — 지역 모듈이 area_contents 에 `region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다. `region_code` 인자는 그 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다). 호출측이 region_code 를 못 주면 캐시 없이 부르고 있다는 뜻이라 경고를 남긴다. diff --git a/solution/backend/services/external/naver.py b/solution/backend/services/external/naver.py index 2ed64e1..34bbda4 100644 --- a/solution/backend/services/external/naver.py +++ b/solution/backend/services/external/naver.py @@ -162,7 +162,7 @@ def _extract_place_id(link: Optional[str]) -> Optional[str]: # ---- 지역 캐시 키 -------------------------------------------------------- # ★ 네이버는 행정구역 코드를 주지 않는다. 도로명주소에서 '시도 + 시군구' 를 뽑아 캐시 키를 만든다. -# 이 키가 local.local_contents.region_code(VARCHAR(10)) 에 들어간다 — 길이를 반드시 지켜야 한다. +# 이 키가 area_contents.region_code(VARCHAR(10)) 에 들어간다 — 길이를 반드시 지켜야 한다. # # 시도 이름은 흔들린다(강원도 ↔ 강원특별자치도). 별칭을 전부 같은 코드로 모아야 # 같은 지역이 두 키로 갈리지 않는다 — 갈리면 캐시가 무의미해진다. @@ -441,7 +441,7 @@ class NaverLocalClient: 거리순이 아니고, 그 지역 안이라는 것만 보장된다. ★ 반드시 지역 캐시를 거쳐 부른다. 같은 지역에 사이트가 50개 생겨도 조회는 1회여야 한다. - 캐싱은 이 클라이언트가 하지 않는다 — local 모듈이 local.local_contents 에 + 캐싱은 이 클라이언트가 하지 않는다 — 지역 모듈이 area_contents 에 `region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다. `region_key_hint` 는 그 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다).""" if not region_key_hint: diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index 87c5f78..844fc4f 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -59,7 +59,7 @@ async def _verified_facts(client, h, pid, extra=None): async def _approved_media(db_engine, pid, alt="2층 목조 건물 외관"): async with db_engine.begin() as c: await c.execute( - text("INSERT INTO media (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) " + text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) " "VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"), {"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg", "s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": alt, "l": "외관"}, @@ -366,7 +366,7 @@ async def test_렌더_보고서의_값이_그대로_박제된다(auth_headers, c async with db_engine.begin() as c: row = (await c.execute( - text("SELECT jsonld, unique_content_count FROM site.site_versions WHERE site_version_id = :v"), + text("SELECT jsonld, unique_content_count FROM site_versions WHERE site_version_id = :v"), {"v": uuid.UUID(r["site_version_id"])}, )).mappings().first() diff --git a/solution/backend/tests/test_collect_pipeline.py b/solution/backend/tests/test_collect_pipeline.py index 5ca4391..204d021 100644 --- a/solution/backend/tests/test_collect_pipeline.py +++ b/solution/backend/tests/test_collect_pipeline.py @@ -29,9 +29,20 @@ async def _ready_place(client, h, category=PlaceCategory.LODGING, kakao="k1"): async def _run_worker(job_id=None): - """워커 1틱 — 큐에서 잡을 집어 실제 파이프라인을 돌린다.""" + """워커를 돌려 큐를 비운다. `job_id` 를 주면 그 잡이 끝날 때까지 돈다. + + ★ 1틱만 돌리면 안 된다. 수집이 끝나면 **지역 이야기 잡(LOCAL_SYNC)이 뒤따라 들어온다** — + 한 틱은 그걸 집어 가고, 정작 기다리던 수집 잡은 PENDING 인 채로 남는다. 테스트는 + `job["result"]` 를 읽다가 KeyError 로 죽는데, 화면에는 "그냥 안 끝난 것" 으로 보인다. + 큐에 뒤따르는 잡이 생길 때마다 이 헬퍼가 조용히 어긋나므로 개수를 세지 않고 비운다. + """ worker = Worker("test-worker", JobQueue(), build_handler(), job_deadline_sec=60) - assert await worker.process_one() is True, "워커가 집을 잡이 없다" + ran = 0 + for _ in range(10): + if not await worker.process_one(): + break + ran += 1 + assert ran, "워커가 집을 잡이 없다" async def test_pipeline_stores_facts_as_candidates(auth_headers, client): diff --git a/solution/backend/tests/test_copy_api.py b/solution/backend/tests/test_copy_api.py index 03f1586..e0e090f 100644 --- a/solution/backend/tests/test_copy_api.py +++ b/solution/backend/tests/test_copy_api.py @@ -50,7 +50,7 @@ async def _place_with_facts(client, h, n_verified=5): async def _faq_rows(db_engine, pid): async with db_engine.begin() as c: return (await c.execute( - text("SELECT question, answer, source_fact_ids, status FROM faqs WHERE place_id = :p ORDER BY sort_order"), + text("SELECT question, answer, source_fact_ids, status FROM place_faqs WHERE place_id = :p ORDER BY sort_order"), {"p": uuid.UUID(pid)}, )).all() @@ -154,7 +154,7 @@ async def test_regeneration_keeps_human_approved_faq(auth_headers, client, db_en # 사람이 승인 async with db_engine.begin() as c: await c.execute( - text("UPDATE faqs SET status = :s WHERE place_id = :p"), + text("UPDATE place_faqs SET status = :s WHERE place_id = :p"), {"s": FactStatus.VERIFIED.value, "p": uuid.UUID(pid)}, ) diff --git a/solution/backend/tests/test_fact_schema.py b/solution/backend/tests/test_fact_schema.py index a357071..e4ff3d3 100644 --- a/solution/backend/tests/test_fact_schema.py +++ b/solution/backend/tests/test_fact_schema.py @@ -44,7 +44,7 @@ async def _insert_fact(db_engine, place_id, key, value, status, unit_id=None): async with db_engine.begin() as conn: await conn.execute( text( - "INSERT INTO facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) " + "INSERT INTO place_facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) " "VALUES (:fid, :pid, :uid, :key, :val, :src, :status, now())" ), { @@ -74,7 +74,7 @@ async def test_candidates_coexist_with_published_value(db_engine, owner_id): async with db_engine.begin() as conn: rows = (await conn.execute( - text("SELECT status FROM facts WHERE place_id = :pid AND key = 'check_in_time'"), + text("SELECT status FROM place_facts WHERE place_id = :pid AND key = 'check_in_time'"), {"pid": uuid.UUID(place_id)}, )).all() assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다" @@ -89,7 +89,7 @@ async def test_rejected_fact_frees_the_key(db_engine, owner_id): async with db_engine.begin() as conn: rows = (await conn.execute( - text("SELECT value, status FROM facts WHERE place_id = :pid ORDER BY status"), + text("SELECT value, status FROM place_facts WHERE place_id = :pid ORDER BY status"), {"pid": uuid.UUID(place_id)}, )).all() assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다" @@ -111,7 +111,7 @@ async def test_same_key_allowed_across_units(db_engine, owner_id): async with db_engine.begin() as conn: for uid, name in ((unit_a, "A동"), (unit_b, "B동")): await conn.execute( - text("INSERT INTO units (unit_id, place_id, name) VALUES (:uid, :pid, :name)"), + text("INSERT INTO place_units (unit_id, place_id, name) VALUES (:uid, :pid, :name)"), {"uid": uid, "pid": uuid.UUID(place_id), "name": name}, ) @@ -129,7 +129,7 @@ async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id): unit_id = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( - text("INSERT INTO units (unit_id, place_id, name) VALUES (:uid, :pid, :name)"), + text("INSERT INTO place_units (unit_id, place_id, name) VALUES (:uid, :pid, :name)"), {"uid": unit_id, "pid": uuid.UUID(place_id), "name": "A동"}, ) await _insert_fact(db_engine, place_id, "has_kitchen", "false", FactStatus.VERIFIED) diff --git a/solution/backend/tests/test_faq_api.py b/solution/backend/tests/test_faq_api.py index 50b0ad0..691318b 100644 --- a/solution/backend/tests/test_faq_api.py +++ b/solution/backend/tests/test_faq_api.py @@ -23,7 +23,7 @@ async def _seed_generated_faq(db_engine, pid, question="체크인은 몇 시인 fid = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( - text("INSERT INTO faqs (faq_id, place_id, question, answer, source_fact_ids, generated_by, status, sort_order) " + text("INSERT INTO place_faqs (faq_id, place_id, question, answer, source_fact_ids, generated_by, status, sort_order) " "VALUES (:f, :p, :q, :a, CAST(:k AS jsonb), :g, :st, :o)"), {"f": fid, "p": uuid.UUID(pid), "q": question, "a": answer, "k": '["check_in_time"]', "g": SourceType.LLM.value, "st": FactStatus.UNVERIFIED.value, "o": order}, diff --git a/solution/backend/tests/test_schema_ddl.py b/solution/backend/tests/test_schema_ddl.py index 9707077..44923e0 100644 --- a/solution/backend/tests/test_schema_ddl.py +++ b/solution/backend/tests/test_schema_ddl.py @@ -36,6 +36,22 @@ def _parse_init_sql() -> dict: return out +# init.sql 에는 있고 ORM 모델은 없는 표. 마이그레이션 대장은 scripts/migrate.py 가 소유하고 +# 애플리케이션 코드가 읽지 않는다 — 모델을 만들면 도메인 표처럼 보인다. +_NOT_ORM = {"public.schema_migrations"} + + +def _model_tables() -> set: + """ORM 모델 → {"public.table", ...} + + ★ 모델은 스키마를 적지 않는다(`Table.schema is None`) — 2026-09-09 에 도메인 스키마를 + 걷어내고 public 한 벌로 폈기 때문이다(migrations/0005). init.sql 은 `public.` 을 + 명시하므로 여기서 같은 모양으로 맞춰 준다. `t.schema` 를 그대로 쓰면 "None.users" 가 + 되어 **모든 표가 누락으로 잡힌다** — 실제로 그렇게 이 테스트가 통째로 빨개졌다. + """ + return {f"{t.schema or 'public'}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables} + + def test_init_sql_is_readable(): """검증: init.sql 을 찾고 파싱할 수 있는지. 기대결과: 파일이 존재하고 CREATE TABLE 이 1개 이상 파싱된다.""" @@ -47,7 +63,7 @@ def test_every_model_table_exists_in_init_sql(): """검증: ORM 모델의 모든 테이블이 init.sql 에도 있는지. 기대결과: 누락 없음 — 모델만 추가하고 마이그레이션을 안 쓴 경우를 잡는다.""" sql_tables = set(_parse_init_sql()) - model_tables = {f"{t.schema}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables} + model_tables = _model_tables() missing = sorted(model_tables - sql_tables) assert not missing, f"init.sql 에 없는 모델 테이블: {missing}" @@ -55,8 +71,8 @@ def test_every_model_table_exists_in_init_sql(): def test_every_init_sql_table_has_a_model(): """검증: init.sql 의 모든 테이블에 ORM 모델이 있는지. 기대결과: 누락 없음 — 마이그레이션만 쓰고 모델을 안 만든 경우를 잡는다.""" - sql_tables = set(_parse_init_sql()) - model_tables = {f"{t.schema}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables} + sql_tables = set(_parse_init_sql()) - _NOT_ORM + model_tables = _model_tables() missing = sorted(sql_tables - model_tables) assert not missing, f"ORM 모델이 없는 init.sql 테이블: {missing}" @@ -66,10 +82,12 @@ def test_columns_match_between_model_and_init_sql(): 기대결과: 완전 일치 — 한쪽에만 추가된 컬럼을 잡는다.""" sql_tables = _parse_init_sql() problems = [] + compared = 0 for table in MAIN_BASE.metadata.sorted_tables: - name = f"{table.schema}.{table.name}" + name = f"{table.schema or 'public'}.{table.name}" if name not in sql_tables: continue + compared += 1 model_cols = {c.name for c in table.columns} sql_cols = sql_tables[name] if model_cols != sql_cols: @@ -77,6 +95,11 @@ def test_columns_match_between_model_and_init_sql(): f"{name}: 모델에만 {sorted(model_cols - sql_cols)} / init.sql 에만 {sorted(sql_cols - model_cols)}" ) assert not problems, "컬럼 불일치:\n" + "\n".join(problems) + # ★ 한 표도 못 찾으면 이 테스트는 아무것도 검사하지 않고 통과한다. 실제로 그랬다 — + # 이름을 "None.users" 로 만들어 전부 건너뛰었고, 그동안 init.sql 이 조용히 어긋났다. + assert compared == len(MAIN_BASE.metadata.sorted_tables), ( + f"init.sql 에서 {compared}/{len(MAIN_BASE.metadata.sorted_tables)} 개만 찾았다 — 이름 규칙이 어긋났다" + ) def test_every_table_has_soft_delete_columns(): diff --git a/solution/backend/tests/test_snapshot.py b/solution/backend/tests/test_snapshot.py index 38ca3e5..a39b7a3 100644 --- a/solution/backend/tests/test_snapshot.py +++ b/solution/backend/tests/test_snapshot.py @@ -25,7 +25,7 @@ async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING): async def _fact(db_engine, pid, key, value, status, unit_id=None): async with db_engine.begin() as c: await c.execute( - text("INSERT INTO facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) " + text("INSERT INTO place_facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) " "VALUES (:f,:p,:u,:k,:v,:s,:st,now())"), {"f": uuid.uuid4(), "p": pid, "u": unit_id, "k": key, "v": value, "s": SourceType.OWNER.value, "st": status.value}, @@ -35,7 +35,7 @@ async def _fact(db_engine, pid, key, value, status, unit_id=None): async def _media(db_engine, pid, url, status, alt="설명"): async with db_engine.begin() as c: await c.execute( - text("INSERT INTO media (media_id, place_id, url, origin_url, source_type, status, alt_text, sort_order) " + text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, sort_order) " "VALUES (:m,:p,:u,:u,:s,:st,:a,0)"), {"m": uuid.uuid4(), "p": pid, "u": url, "s": SourceType.CRAWL.value, "st": status.value, "a": alt}, ) @@ -110,7 +110,7 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id): pid = await _seed(db_engine, owner_id) uid = uuid.uuid4() async with db_engine.begin() as c: - await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"), + await c.execute(text("INSERT INTO place_units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"), {"u": uid, "p": pid, "n": "A동"}) await _fact(db_engine, pid, "max_capacity", "4", FactStatus.VERIFIED, unit_id=uid) @@ -133,17 +133,27 @@ async def test_empty_place_gives_empty_snapshot(db_engine, owner_id): # ★ 지역 정보가 스냅샷에 담기는 이유: site_payload 는 DB 를 다시 읽지 않는다. # 거기서 지역 캐시를 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 '스냅샷과 다른 페이지'가 나온다. -async def _local(db_engine, region_code, content_type, status, title="지역행사", **cols): - from common.enums import LocalSource +async def _local(db_engine, region_code, kind, status, title="지역이야기", **cols): + """지역 캐시(`area_contents`)에 **지역 단위** 항목 한 행. + + ★ `external_id` 를 넣지 않는다. 그 값이 있는 행(축제·관광지·맛집)은 업장마다 거리가 달라 + 개인화(`site_sections`)를 거쳐 들어오고, 스냅샷의 지역 캐시 경로는 그것들을 일부러 + 건너뛴다(`services/snapshot._local_contents`). 예전 이 픽스처는 uuid 를 external_id 로 + 넣고 있어서, **읽는 코드가 옳게 걸러내는데도 테스트가 빨개졌다**. + ★ 한 지역에 같은 kind 는 한 행이다(`uq_local_contents_kind`). 여러 행이 필요한 테스트는 + kind 를 달리 준다 — 지역 이야기 다섯 종이 그 자리다. + """ + from common.enums import LocalContentType, LocalSource async with db_engine.begin() as c: await c.execute( - text("INSERT INTO local_contents " - "(local_content_id, region_code, content_type, source, external_id, title, body, status, " + text("INSERT INTO area_contents " + "(local_content_id, region_code, content_type, source, title, body, status, kind, " " display_start_at, display_end_at, collected_at) " - "VALUES (:i,:r,:ct,:src,:ext,:t,cast(:b as jsonb),:st,:ds,:de,now())"), - {"i": uuid.uuid4(), "r": region_code, "ct": content_type.value, "src": LocalSource.TOUR_API.value, - "ext": uuid.uuid4().hex, "t": title, "b": '{"title":"%s"}' % title, "st": status.value, + "VALUES (:i,:r,:ct,:src,:t,cast(:b as jsonb),:st,:k,:ds,:de,now())"), + {"i": uuid.uuid4(), "r": region_code, "ct": LocalContentType.STORY.value, + "src": LocalSource.LLM.value, "t": title, + "b": '{"title":"%s"}' % title, "st": status.value, "k": kind, "ds": cols.get("display_start_at"), "de": cols.get("display_end_at")}, ) @@ -160,15 +170,16 @@ async def test_only_published_local_content_enters_snapshot(db_engine, owner_id) """검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다. 기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면 '미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙).""" - from common.enums import LocalContentStatus, LocalContentType + from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) - await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제") - await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제") - await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제") + # kind 를 달리 준다 — 한 지역에 같은 kind 는 한 행이다(uq_local_contents_kind). + await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "발행이야기") + await _local(db_engine, "4113500", "people", LocalContentStatus.REVIEW, "검수대기이야기") + await _local(db_engine, "4113500", "chronicle", LocalContentStatus.ENDED, "종료이야기") snap = await build_snapshot(_RegionPlace(pid, "4113500")) - assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"] + assert [c["title"] for c in snap["local"]["contents"]] == ["발행이야기"] async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id): @@ -176,56 +187,56 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, owner 기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다.""" from datetime import datetime, timedelta, timezone - from common.enums import LocalContentStatus, LocalContentType + from common.enums import LocalContentStatus now = datetime.now(timezone.utc) pid = await _seed(db_engine, owner_id) - await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제") - await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제", + await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "지금이야기") + await _local(db_engine, "4113500", "people", LocalContentStatus.PUBLISHED, "끝난이야기", display_end_at=now - timedelta(days=1)) - await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "아직축제", + await _local(db_engine, "4113500", "chronicle", LocalContentStatus.PUBLISHED, "아직이야기", display_start_at=now + timedelta(days=1)) snap = await build_snapshot(_RegionPlace(pid, "4113500")) - assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"] + assert [c["title"] for c in snap["local"]["contents"]] == ["지금이야기"] async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id): """검증: 지역 캐시는 region_code 로 묶인다. 기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다.""" - from common.enums import LocalContentStatus, LocalContentType + from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) - await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제") - await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제") + await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "우리지역이야기") + await _local(db_engine, "5011025", "songs", LocalContentStatus.PUBLISHED, "남의지역이야기") snap = await build_snapshot(_RegionPlace(pid, "4113500")) - assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"] + assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역이야기"] async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id): """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장. 기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는 코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다.""" - from common.enums import LocalContentStatus, LocalContentType + from common.enums import LocalContentStatus from services.external.naver import region_key derived = region_key(_Place("x").road_address) pid = await _seed(db_engine, owner_id) - await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제") + await _local(db_engine, derived, "songs", LocalContentStatus.PUBLISHED, "양양이야기") snap = await build_snapshot(_RegionPlace(pid, "")) assert snap["local"]["region_code"] == derived - assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"] + assert [c["title"] for c in snap["local"]["contents"]] == ["양양이야기"] async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id): """검증: 지역 코드도 읽을 만한 주소도 없는 사업장. 기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다.""" - from common.enums import LocalContentStatus, LocalContentType + from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) - await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제") + await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "어딘가이야기") place = _RegionPlace(pid, "") place.road_address = None diff --git a/solution/backend/tests/test_tour_api_adapter.py b/solution/backend/tests/test_tour_api_adapter.py index 5b82c13..a99fa63 100644 --- a/solution/backend/tests/test_tour_api_adapter.py +++ b/solution/backend/tests/test_tour_api_adapter.py @@ -107,13 +107,23 @@ def test_lodging_place_facts(): def test_lodging_drops_fields_without_schema_slot(): - """검증: 스키마에 자리가 없는 TourAPI 필드(roomcount·scalelodging·foodplace). + """검증: 스키마에 자리가 없는 TourAPI 필드(foodplace). 기대결과: 아무 key 로도 들어오지 않는다. ★ 억지로 다른 key 에 넣으면 - '식음료장 있음' 이 '조식 제공' 으로 둔갑한다.""" - values = {v for v in _map(ADAPTER._lodging_facts(_LODGING_INTRO)).values()} - assert "13실" not in values and "있음" not in values - assert not any("11,570" in str(v) for v in values) + '식음료장 있음' 이 '조식 제공' 으로 둔갑한다. + + ★ roomcount·scalelodging 은 여기서 빠져 있었다. 2026-09-07 에 lodging 스키마에 + 자리가 생겨서(total_rooms·building_scale) 이제 **버리지 않는다** — 자리가 없어 + 버려지던 값이 TourAPI 응답의 절반이었다(실측 오블로모프 3103191). + "자리가 없으면 버린다" 는 규칙은 그대로고, 자리가 생겼을 뿐이다.""" + facts = _map(ADAPTER._lodging_facts(_LODGING_INTRO)) + values = set(facts.values()) + assert "있음" not in values, "foodplace 는 자리가 없다 — 버려야 한다" + # 원문 그대로가 아니라 스키마가 정한 모양으로 들어간다. + assert facts[("total_rooms", None)] == "13", "'13실' 에서 숫자만 뽑는다(unit 은 스키마가 안다)" + assert facts[("building_scale", None)] == "대지 면적 11,570㎡", ( + "규모는 단위가 제각각이라 숫자로 뽑지 않고 원문을 싣는다" + ) def test_room_facts_are_unit_scoped_and_use_square_meters(): diff --git a/solution/backend/tests/test_vision_api.py b/solution/backend/tests/test_vision_api.py index c7e37fd..5851682 100644 --- a/solution/backend/tests/test_vision_api.py +++ b/solution/backend/tests/test_vision_api.py @@ -23,7 +23,7 @@ async def _place_with_media(client, h, db_engine, n=3, kakao="v1"): async with db_engine.begin() as c: for i in range(n): await c.execute( - text("INSERT INTO media (media_id, place_id, url, origin_url, source_type, status, sort_order) " + text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, sort_order) " "VALUES (:m, :p, :u, :u, :s, :st, :o)"), {"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{pid}/{i}.jpg", "s": SourceType.CRAWL.value, "st": MediaStatus.PENDING_REVIEW.value, "o": i}, @@ -34,7 +34,7 @@ async def _place_with_media(client, h, db_engine, n=3, kakao="v1"): async def _media_rows(db_engine, pid): async with db_engine.begin() as c: return (await c.execute( - text("SELECT origin_url, label, alt_text, vision_confidence, status FROM media " + text("SELECT origin_url, label, alt_text, vision_confidence, status FROM place_photos " "WHERE place_id = :p ORDER BY sort_order"), {"p": uuid.UUID(pid)}, )).all() diff --git a/solution/backend/tests/test_weather_api.py b/solution/backend/tests/test_weather_api.py index 22be982..2e6f38f 100644 --- a/solution/backend/tests/test_weather_api.py +++ b/solution/backend/tests/test_weather_api.py @@ -42,7 +42,7 @@ async def test_weather_returns_stale_cache_when_upstream_fails(client, db_engine async with db_engine.begin() as conn: from sqlalchemy import text - await conn.execute(text("UPDATE local.local_contents SET expires_at = :expired"), { + await conn.execute(text("UPDATE area_contents SET expires_at = :expired"), { "expired": datetime.now(timezone.utc) - timedelta(minutes=1) })