[docs] docs: 값이 DB 에서 페이지까지 가는 길을 한 장으로 — 옛 표 이름 잔재도 정리
표 14개가 무엇을 담고 누가 쓰는지 적어 둔 곳이 없었다. 컬럼 주석은 models.py 에만 있고, "이 값이 왜 화면에 안 나오나" 를 짚으려면 snapshot.py·build_service.py·site_payload.py 를 차례로 열어야 했다. - docs/DATA_MODEL.md 신설. 흐름(등록→검증→수집→에디터→빌드→발행) · 표별 칸과 쓰임 · 값 하나가 페이지까지 가는 길(두 번 도는 게이트) · **DB 에 없는 것** · 표를 고칠 때 - 옛 표 이름이 남아 있던 자리를 현재 이름으로. 재편(0005~0008)이 지나간 뒤로 문서만 옛 이름을 들고 있었다 — `place_links`(COLLECTION) · `local_contents`·`job.jobs`· `company.users`·`fact.facts`(DECISIONS) · `ai_check_results`(DEVELOPMENT_DIRECTION, 0006 이 뗀 표다) - ARCHITECTURE 2절의 프리렌더 컨테이너 이름이 `solution-frontend` 였다. 실제로 굽는 것은 `solution-prerender` 고, 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔 바로 그 혼동을 문서가 만들고 있었다 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
534122dccf
commit
3e62d08e39
@ -7,6 +7,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) |
|
| 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) |
|
||||||
| 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.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) |
|
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
|
||||||
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
|
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
|
||||||
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
|
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
|
||||||
|
|||||||
@ -35,7 +35,7 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
|
|||||||
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
|
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
|
||||||
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
|
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
|
||||||
│
|
│
|
||||||
│ ┌ [별도 컨테이너 o2o-web4ai-solution-frontend]
|
│ ┌ [별도 컨테이너 solution-prerender] ★ solution-frontend 가 아니다(개발용)
|
||||||
│ │ scripts/watch-payloads.mjs — payloads/ 2초 폴링, 바뀐 것만
|
│ │ scripts/watch-payloads.mjs — payloads/ 2초 폴링, 바뀐 것만
|
||||||
│ │ └ node dist/prerender/prerender.js --payload=<file>
|
│ │ └ node dist/prerender/prerender.js --payload=<file>
|
||||||
│ │ → out/s/<slug>/** + out/assets, out/fonts, out/robots.txt …
|
│ │ → out/s/<slug>/** + out/assets, out/fonts, out/robots.txt …
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
- 검색 대상: 야놀자, 여기어때, 네이버 플레이스 등 허용된 도메인
|
- 검색 대상: 야놀자, 여기어때, 네이버 플레이스 등 허용된 도메인
|
||||||
- 제외 대상: 서비스 홈, 목록, 블로그·카페 후기
|
- 제외 대상: 서비스 홈, 목록, 블로그·카페 후기
|
||||||
|
|
||||||
Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 않는다. URL 후보만 `place_links`에 미확정 상태로 저장한다.
|
Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지 않는다. URL 후보만 `place_channels`에 미확정 상태로 저장한다.
|
||||||
|
|
||||||
사용자가 `내 채널 맞아요`로 확인한 링크만 크롤링과 사이트 노출에 사용한다.
|
사용자가 `내 채널 맞아요`로 확인한 링크만 크롤링과 사이트 노출에 사용한다.
|
||||||
|
|
||||||
|
|||||||
285
docs/DATA_MODEL.md
Normal file
285
docs/DATA_MODEL.md
Normal file
@ -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/<slug>.json ★ 백엔드의 유일한 산출물
|
||||||
|
│
|
||||||
|
│ (컨테이너 경계)
|
||||||
|
▼
|
||||||
|
solution-prerender 컨테이너
|
||||||
|
scripts/watch-payloads.mjs → prerender.ts
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
out/s/<slug>/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/<slug>.json
|
||||||
|
└ prerender.ts → out/s/<slug>/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/<slug>.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건이 남았고, 가게 등록 · 수집 시작 · 수집 완료 세 곳이 연달아
|
||||||
|
죽었다. 기동은 정상이라 로그를 열기 전에는 안 보였다.
|
||||||
@ -72,7 +72,7 @@
|
|||||||
| 상태 | **미결** (2026-09-02 구글 로그인 붙이면서 생김) |
|
| 상태 | **미결** (2026-09-02 구글 로그인 붙이면서 생김) |
|
||||||
| 필요한 결론 | 이미 id/pw 로 가입한 사람이 같은 이메일의 구글로 로그인했을 때, 같은 계정으로 이을 것인가. 이으려면 **먼저 가입한 쪽의 소유 증명**(비밀번호 재입력 또는 이메일 인증)을 어디에 둘 것인가 |
|
| 필요한 결론 | 이미 id/pw 로 가입한 사람이 같은 이메일의 구글로 로그인했을 때, 같은 계정으로 이을 것인가. 이으려면 **먼저 가입한 쪽의 소유 증명**(비밀번호 재입력 또는 이메일 인증)을 어디에 둘 것인가 |
|
||||||
| 왜 지금 안 푸나 | 이메일만 보고 자동으로 이으면 **계정 선점**이 된다 — 공격자가 남의 이메일로 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`)로 빼고, 계정 하나에 수단 여러 개를 매단다. 지금 구조가 그 이행을 막지 않는다 |
|
| 결론이 "잇는다" 일 때 | `provider`·`provider_uid` 를 users 에서 별도 테이블(`user_identities`)로 빼고, 계정 하나에 수단 여러 개를 매단다. 지금 구조가 그 이행을 막지 않는다 |
|
||||||
| 확정 사항 | **구글 ID 토큰의 `aud`(우리 client_id)와 `email_verified` 검증은 결론과 무관하게 필수다.** `tests/test_google_identity.py` 가 이 둘을 고정한다 |
|
| 확정 사항 | **구글 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 다음 번호 |
|
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
|
||||||
| DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 |
|
| 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` 가 대조하는 구조라, 세 번째 정의를 더하면 어긋날 자리가 하나 더 생긴다 |
|
| 마이그레이션 | 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 모듈과 함께 재이식 예정 |
|
| 뺀 것 | 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__` 를 건드려서 따로 둔다 |
|
| `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 | 원본이 구간을 나눠 쓰는 방식 유지 |
|
| 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 가 중복된다 |
|
| 유니크 인덱스 2분할 | `unit_id IS NULL` / `IS NOT NULL` 로 나눠 건다 | Postgres 에서 NULL 끼리는 유니크가 안 걸린다. 나누지 않으면 사업장 단위 fact 가 중복된다 |
|
||||||
| `critical` 플래그 | 업종 스키마 필드 속성으로 도입 | 절대규칙 1(미검증 fact 노출 금지)의 대상 목록이 코드가 아니라 데이터에 있어야 업종 추가 시 자동으로 따라온다 |
|
| `critical` 플래그 | 업종 스키마 필드 속성으로 도입 | 절대규칙 1(미검증 fact 노출 금지)의 대상 목록이 코드가 아니라 데이터에 있어야 업종 추가 시 자동으로 따라온다 |
|
||||||
| `allow_llm` 플래그 | 기본 `False`. `True` 는 소개문 계열 2개뿐 | 절대규칙 7(LLM 은 사실을 만들지 않는다)을 스키마 레벨에서 강제. 테스트가 `required` 필드의 `allow_llm=True` 를 금지한다 |
|
| `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 비예약어라 그대로 쓸 수 있다(확인함) |
|
| 스키마 네임스페이스 | `place` / `fact` / `local` / `site` | 원본의 도메인별 schema 컨벤션. `local` 은 Postgres 비예약어라 그대로 쓸 수 있다(확인함) |
|
||||||
| 테이블명 | 복수형 (`places`, `facts`) | 원본이 복수형(`companies`, `users`, `quotations`). 스펙 문서의 단수 표기는 엔티티 이름으로 읽었다 |
|
| 테이블명 | 복수형 (`places`, `facts`) | 원본이 복수형(`companies`, `users`, `quotations`). 스펙 문서의 단수 표기는 엔티티 이름으로 읽었다 |
|
||||||
| `server_default` | 신규 도메인 테이블에만 추가 | ORM `default=` 는 Python 쪽이라 raw INSERT 에 안 먹는다. `create_all`(테스트 DB)과 `init.sql`(실 DB)이 갈라져서 실제로 버그가 났다. **`companies`/`users` 는 원본 그대로 두었다** |
|
| `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번 이후.
|
- **`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번 참고.
|
- **TourAPI areaCode ↔ 카카오 행정구역 코드 매핑** — 테이블 대신 `common/category_schema` 와 같은 리소스 JSON 으로 두는 것을 제안. 3번 참고.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -172,7 +172,7 @@
|
|||||||
| 승인 | 후보 → 노출값, 옛 값 EXPIRED | `PUBLISHED_REPLACED` |
|
| 승인 | 후보 → 노출값, 옛 값 EXPIRED | `PUBLISHED_REPLACED` |
|
||||||
| 수정(사람 직접) | 즉시 노출값 교체 | `PUBLISHED_REPLACED` |
|
| 수정(사람 직접) | 즉시 노출값 교체 | `PUBLISHED_REPLACED` |
|
||||||
|
|
||||||
스키마: `postgres-init/init-data/init.sql` (`fact.facts` 활성 유니크 + 후보 상태)
|
스키마: `postgres-init/init-data/init.sql` (`place_facts` 활성 유니크 + 후보 상태)
|
||||||
|
|
||||||
### 5-2. 그 밖의 결정
|
### 5-2. 그 밖의 결정
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
| A6 JSON-LD | **구현** | 업종별 JSON-LD, FAQPage, Breadcrumb, WebPage, sameAs | 법률·의료 도입 시 타입·자격·저자 스키마 확장 |
|
| A6 JSON-LD | **구현** | 업종별 JSON-LD, FAQPage, Breadcrumb, WebPage, sameAs | 법률·의료 도입 시 타입·자격·저자 스키마 확장 |
|
||||||
| A7 3-way 일치성 | **부분 구현** | JSON-LD↔표시 텍스트 검증, 생성문↔fact 근거 검사, publish gate | 원본↔발행본 SimHash 중복도, 모든 사실 문장의 fact ID 역참조 보고서 |
|
| 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 제출 자동화 여부 |
|
| 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
|
### 3-2. Brand AEO B1~B9
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user