From 64ce467f2140248c1393bc6cf9e686ca14caab31 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 9 Sep 2026 17:08:02 +0900 Subject: [PATCH] =?UTF-8?q?[refactor]=20postgres-init,solution:=20DB=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=9E=AC=ED=8E=B8=20=E2=80=94=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=ED=95=B4=EC=B2=B4=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=95=9C=20=EB=B2=8C?= =?UTF-8?q?=20=C2=B7=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B2=B4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다. 스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다. - 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니 같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로 종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다. - migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에 컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다. DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다"). Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를 더하면 어긋날 자리가 하나 더 생기기 때문이다. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 8 +- admin/frontend/src/pages/LocalContentPage.tsx | 40 +- docker-compose.yml | 3 + docs/DECISIONS.md | 2 +- postgres-init/init-data/init.sql | 94 ++++ .../0001_place_contents_external_category.sql | 30 ++ .../migrations/0002_spots_shared.sql | 76 +++ .../migrations/0003_site_contents.sql | 75 +++ .../migrations/0004_local_contents_unify.sql | 102 ++++ .../migrations/0005_flatten_schemas.sql | 85 ++++ .../migrations/0006_prune_unused.sql | 27 ++ postgres-init/migrations/README.md | 34 ++ .../category_schema/resources/lodging.json | 13 + .../backend/common/database/model/models.py | 176 +++---- solution/backend/common/enums.py | 10 +- solution/backend/common/utils/geo.py | 24 + solution/backend/crud/fact_crud.py | 100 ++-- solution/backend/crud/faq_crud.py | 32 +- solution/backend/crud/local_content_crud.py | 82 ++-- solution/backend/crud/media_crud.py | 24 +- solution/backend/crud/place_content_crud.py | 133 ++++++ solution/backend/crud/place_crud.py | 58 +-- solution/backend/crud/site_crud.py | 12 +- solution/backend/router/v1/local/local.py | 41 +- solution/backend/router/v1/local/protocol.py | 49 +- solution/backend/router/v1/place/protocol.py | 2 + solution/backend/scripts/migrate.py | 111 +++++ solution/backend/services/build_service.py | 28 +- solution/backend/services/collect_service.py | 6 +- .../services/collector/tour_api_adapter.py | 23 +- solution/backend/services/copy_service.py | 18 +- solution/backend/services/external/kakao.py | 25 +- .../backend/services/external/tour_api.py | 312 +++++++++++-- solution/backend/services/fact_service.py | 28 +- solution/backend/services/faq_service.py | 14 +- solution/backend/services/itinerary.py | 147 ++++++ .../backend/services/local_content_service.py | 385 +++++++++++++-- solution/backend/services/media_service.py | 6 +- solution/backend/services/place_category.py | 36 ++ solution/backend/services/place_service.py | 27 +- solution/backend/services/site_payload.py | 127 ++++- solution/backend/services/site_service.py | 8 +- solution/backend/services/snapshot.py | 158 ++++--- solution/backend/services/vision_service.py | 8 +- solution/backend/tests/test_faq_api.py | 4 +- solution/frontend/public/robots.txt | 2 + solution/frontend/public/sitemap.xml | 10 + solution/frontend/src/data/industryData.ts | 33 +- .../src/features/builder/CanvasView.tsx | 3 + .../src/features/builder/ItemFormEditor.tsx | 275 +++++++++++ .../src/features/builder/RightTabsPanel.tsx | 54 ++- .../features/builder/SectionDesignPanel.tsx | 126 ++++- .../src/features/builder/TemplatePreview.tsx | 73 +++ .../src/features/builder/canvas/dataSpec.ts | 442 +++++++++--------- .../canvas/primitives/PlaceCarousel.tsx | 205 ++++++++ .../builder/canvas/primitives/Rail.tsx | 234 ++++++++++ .../canvas/primitives/WalkFilterTabs.tsx | 58 +++ .../builder/canvas/primitives/index.ts | 4 + .../src/features/builder/canvas/registry.ts | 149 ++---- .../variants/chronicle/ChronicleRail.tsx | 30 +- .../canvas/variants/course/CourseTickets.tsx | 173 ------- .../canvas/variants/hero/HeroCover.tsx | 84 ++++ .../canvas/variants/hero/HeroEditorial.tsx | 66 --- .../canvas/variants/hero/HeroFullBleed.tsx | 54 --- .../canvas/variants/hero/HeroTypeOnly.tsx | 47 -- .../builder/canvas/variants/items/common.tsx | 18 +- .../builder/canvas/variants/items/items.css | 18 + .../variants/itinerary/ItineraryTickets.tsx | 179 +++++++ .../variants/literature/LiteratureShelf.tsx | 149 ------ .../canvas/variants/local/LiveClock.tsx | 27 -- .../variants/local/LocalCategorySection.tsx | 61 +++ .../canvas/variants/local/LocalCompact.tsx | 50 -- .../canvas/variants/local/LocalFull.tsx | 163 ------- .../canvas/variants/local/LocalGuide.tsx | 88 ++++ .../canvas/variants/local/LocalTabs.tsx | 124 ----- .../builder/canvas/variants/local/types.ts | 32 +- .../builder/canvas/variants/local/walking.ts | 36 ++ .../variants/people/PeopleFilmstrip.tsx | 16 +- .../canvas/variants/photos/PhotosCarousel.tsx | 19 +- .../canvas/variants/planner/PlannerPodium.tsx | 218 --------- .../variants/postcard/PostcardStack.tsx | 20 +- .../builder/canvas/variants/quiz/QuizFlip.tsx | 20 +- .../canvas/variants/rules/RulesCards.tsx | 51 -- .../canvas/variants/rules/RulesList.tsx | 58 --- .../variants/schedule/ScheduleTimetable.tsx | 180 ------- .../canvas/variants/songs/SongsTurntable.tsx | 73 ++- .../canvas/variants/video/VideoFrame.tsx | 90 ++++ .../frontend/src/features/dev/tokenGroups.ts | 4 +- solution/frontend/src/hooks/useLocalGuide.ts | 113 +++++ solution/frontend/src/index.css | 3 + solution/shared/package.json | 3 +- solution/shared/src/lib/color.ts | 9 +- solution/shared/src/lib/facts.ts | 16 + solution/shared/src/lib/section-data.ts | 220 +++++++-- solution/shared/src/styles/base.css | 21 +- solution/shared/src/styles/site.css | 258 ++++++++++ solution/shared/src/types/builder.ts | 6 +- solution/shared/src/types/site-payload.ts | 55 ++- solution/site/index.html | 2 +- solution/site/scripts/prerender.ts | 5 +- solution/site/src/App.tsx | 52 ++- solution/site/src/index.css | 202 +++----- solution/site/src/layouts/editorial/Hero.tsx | 162 +++++++ solution/site/src/layouts/editorial/Rooms.tsx | 240 ++++++++++ .../src/layouts/editorial/SectionHead.tsx | 189 ++++++++ solution/site/src/layouts/editorial/Shell.tsx | 236 ++++++++++ solution/site/src/layouts/editorial/index.ts | 14 + solution/site/src/layouts/index.ts | 17 + solution/site/src/layouts/oasi/Hero.tsx | 109 +++++ solution/site/src/layouts/oasi/Rooms.tsx | 137 ++++++ .../site/src/layouts/oasi/SectionHead.tsx | 40 ++ solution/site/src/layouts/oasi/Shell.tsx | 398 ++++++++++++++++ solution/site/src/layouts/oasi/index.ts | 11 + solution/site/src/layouts/pastel/Hero.tsx | 202 ++++++++ solution/site/src/layouts/pastel/Rooms.tsx | 181 +++++++ .../site/src/layouts/pastel/SectionHead.tsx | 47 ++ solution/site/src/layouts/pastel/Shell.tsx | 207 ++++++++ solution/site/src/layouts/pastel/index.ts | 4 + .../site/src/layouts/reservation/Hero.tsx | 308 ++++++++++++ .../site/src/layouts/reservation/Rooms.tsx | 194 ++++++++ .../src/layouts/reservation/SectionHead.tsx | 52 +++ .../site/src/layouts/reservation/Shell.tsx | 354 ++++++++++++++ .../site/src/layouts/reservation/index.ts | 10 + solution/site/src/layouts/studio/Hero.tsx | 54 +++ solution/site/src/layouts/studio/Rooms.tsx | 112 +++++ .../site/src/layouts/studio/SectionHead.tsx | 57 +++ solution/site/src/layouts/studio/Shell.tsx | 218 +++++++++ solution/site/src/layouts/studio/index.ts | 8 + solution/site/src/lib/derive.ts | 160 ++++++- solution/site/src/lib/format.ts | 62 +++ solution/site/src/lib/layout.ts | 32 ++ solution/site/src/lib/ui/Carousel.tsx | 245 ++++++++++ solution/site/src/lib/ui/Section.tsx | 133 ++++++ solution/site/src/lib/ui/index.ts | 3 + solution/site/src/lib/ui/primitives.tsx | 80 ++++ solution/site/src/lib/use-active-section.ts | 41 -- solution/site/src/lib/use-live-seasons.ts | 16 + solution/site/src/pages/HomePage.tsx | 44 +- solution/site/src/sections/AboutSection.tsx | 79 ++-- solution/site/src/sections/AnswerBlock.tsx | 21 +- solution/site/src/sections/BookingSection.tsx | 97 ++-- .../src/sections/EssentialInfoSection.tsx | 274 ++++++----- .../site/src/sections/ExhibitionSection.tsx | 57 +-- solution/site/src/sections/FaqSection.tsx | 67 +-- .../site/src/sections/FestivalSection.tsx | 320 +++++++------ solution/site/src/sections/GallerySection.tsx | 331 ++++++++----- solution/site/src/sections/HeroPension.tsx | 224 +++++++++ solution/site/src/sections/HeroSection.tsx | 68 +-- solution/site/src/sections/HeroSplit.tsx | 123 +++++ solution/site/src/sections/InquirySection.tsx | 130 +++--- .../site/src/sections/LocalGuideSection.tsx | 424 +++++++++-------- .../site/src/sections/LocationSection.tsx | 249 +++++----- solution/site/src/sections/MobileTabBar.tsx | 95 ++-- solution/site/src/sections/PricingSection.tsx | 86 ++++ solution/site/src/sections/RulesSection.tsx | 65 --- solution/site/src/sections/SectionShell.tsx | 68 --- solution/site/src/sections/SiteFooter.tsx | 73 ++- solution/site/src/sections/SiteHeader.tsx | 100 ++-- solution/site/src/sections/SpaceSection.tsx | 50 +- solution/site/src/sections/StorySection.tsx | 101 ---- solution/site/src/sections/UnitsBands.tsx | 136 ++++++ solution/site/src/sections/UnitsSection.tsx | 305 ++++++------ solution/site/src/sections/UnitsTabs.tsx | 150 ++++++ solution/site/src/sections/WeatherSection.tsx | 119 +++-- solution/site/src/sections/index.ts | 5 +- .../src/sections/items/ChronicleSection.tsx | 3 +- .../site/src/sections/items/CourseSection.tsx | 88 ---- .../site/src/sections/items/DailySection.tsx | 154 ++++-- .../site/src/sections/items/EventSection.tsx | 342 ++++++++++++-- .../src/sections/items/ItinerarySection.tsx | 385 ++++++++------- .../src/sections/items/LiteratureSection.tsx | 70 --- .../site/src/sections/items/PeopleSection.tsx | 17 +- .../src/sections/items/PlannerSection.tsx | 50 +- .../src/sections/items/PostcardSection.tsx | 5 +- .../site/src/sections/items/QuizSection.tsx | 89 ++-- solution/site/src/sections/items/RouteMap.tsx | 240 ++++++++++ .../src/sections/items/ScheduleSection.tsx | 95 ---- .../site/src/sections/items/SongsSection.tsx | 111 +++-- .../site/src/sections/items/StorySection.tsx | 107 +++++ solution/site/src/sections/items/TripMap.tsx | 128 ----- .../site/src/sections/items/VideoSection.tsx | 225 +++++++-- solution/site/src/sections/items/common.tsx | 163 +++++-- solution/site/src/sections/items/index.ts | 21 +- solution/site/src/sections/items/items.css | 51 +- solution/site/src/sections/weather.css | 125 +++++ solution/site/src/seo/index.ts | 1 + solution/site/src/seo/jsonld.ts | 42 +- solution/site/src/seo/meta.ts | 66 ++- solution/site/src/seo/security.ts | 41 ++ 189 files changed, 13374 insertions(+), 5023 deletions(-) create mode 100644 postgres-init/migrations/0001_place_contents_external_category.sql create mode 100644 postgres-init/migrations/0002_spots_shared.sql create mode 100644 postgres-init/migrations/0003_site_contents.sql create mode 100644 postgres-init/migrations/0004_local_contents_unify.sql create mode 100644 postgres-init/migrations/0005_flatten_schemas.sql create mode 100644 postgres-init/migrations/0006_prune_unused.sql create mode 100644 postgres-init/migrations/README.md create mode 100644 solution/backend/common/utils/geo.py create mode 100644 solution/backend/crud/place_content_crud.py create mode 100644 solution/backend/scripts/migrate.py create mode 100644 solution/backend/services/itinerary.py create mode 100644 solution/frontend/public/robots.txt create mode 100644 solution/frontend/public/sitemap.xml create mode 100644 solution/frontend/src/features/builder/ItemFormEditor.tsx create mode 100644 solution/frontend/src/features/builder/TemplatePreview.tsx create mode 100644 solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx create mode 100644 solution/frontend/src/features/builder/canvas/primitives/Rail.tsx create mode 100644 solution/frontend/src/features/builder/canvas/primitives/WalkFilterTabs.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/course/CourseTickets.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/hero/HeroCover.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/hero/HeroEditorial.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/hero/HeroFullBleed.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/hero/HeroTypeOnly.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/literature/LiteratureShelf.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/LiveClock.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/LocalCompact.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/LocalFull.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/LocalTabs.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/local/walking.ts delete mode 100644 solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/rules/RulesCards.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/rules/RulesList.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/video/VideoFrame.tsx create mode 100644 solution/frontend/src/hooks/useLocalGuide.ts create mode 100644 solution/shared/src/styles/site.css create mode 100644 solution/site/src/layouts/editorial/Hero.tsx create mode 100644 solution/site/src/layouts/editorial/Rooms.tsx create mode 100644 solution/site/src/layouts/editorial/SectionHead.tsx create mode 100644 solution/site/src/layouts/editorial/Shell.tsx create mode 100644 solution/site/src/layouts/editorial/index.ts create mode 100644 solution/site/src/layouts/index.ts create mode 100644 solution/site/src/layouts/oasi/Hero.tsx create mode 100644 solution/site/src/layouts/oasi/Rooms.tsx create mode 100644 solution/site/src/layouts/oasi/SectionHead.tsx create mode 100644 solution/site/src/layouts/oasi/Shell.tsx create mode 100644 solution/site/src/layouts/oasi/index.ts create mode 100644 solution/site/src/layouts/pastel/Hero.tsx create mode 100644 solution/site/src/layouts/pastel/Rooms.tsx create mode 100644 solution/site/src/layouts/pastel/SectionHead.tsx create mode 100644 solution/site/src/layouts/pastel/Shell.tsx create mode 100644 solution/site/src/layouts/pastel/index.ts create mode 100644 solution/site/src/layouts/reservation/Hero.tsx create mode 100644 solution/site/src/layouts/reservation/Rooms.tsx create mode 100644 solution/site/src/layouts/reservation/SectionHead.tsx create mode 100644 solution/site/src/layouts/reservation/Shell.tsx create mode 100644 solution/site/src/layouts/reservation/index.ts create mode 100644 solution/site/src/layouts/studio/Hero.tsx create mode 100644 solution/site/src/layouts/studio/Rooms.tsx create mode 100644 solution/site/src/layouts/studio/SectionHead.tsx create mode 100644 solution/site/src/layouts/studio/Shell.tsx create mode 100644 solution/site/src/layouts/studio/index.ts create mode 100644 solution/site/src/lib/layout.ts create mode 100644 solution/site/src/lib/ui/Carousel.tsx create mode 100644 solution/site/src/lib/ui/Section.tsx create mode 100644 solution/site/src/lib/ui/index.ts create mode 100644 solution/site/src/lib/ui/primitives.tsx delete mode 100644 solution/site/src/lib/use-active-section.ts create mode 100644 solution/site/src/lib/use-live-seasons.ts create mode 100644 solution/site/src/sections/HeroPension.tsx create mode 100644 solution/site/src/sections/HeroSplit.tsx create mode 100644 solution/site/src/sections/PricingSection.tsx delete mode 100644 solution/site/src/sections/RulesSection.tsx delete mode 100644 solution/site/src/sections/SectionShell.tsx delete mode 100644 solution/site/src/sections/StorySection.tsx create mode 100644 solution/site/src/sections/UnitsBands.tsx create mode 100644 solution/site/src/sections/UnitsTabs.tsx delete mode 100644 solution/site/src/sections/items/CourseSection.tsx delete mode 100644 solution/site/src/sections/items/LiteratureSection.tsx create mode 100644 solution/site/src/sections/items/RouteMap.tsx delete mode 100644 solution/site/src/sections/items/ScheduleSection.tsx create mode 100644 solution/site/src/sections/items/StorySection.tsx delete mode 100644 solution/site/src/sections/items/TripMap.tsx create mode 100644 solution/site/src/sections/weather.css create mode 100644 solution/site/src/seo/security.ts diff --git a/AGENTS.md b/AGENTS.md index b682a08..0413d1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,9 @@ docker compose logs -f solution-worker - **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf` (후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다) - DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`). - 스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다 + 스키마는 `postgres-init/init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/` + (이미 만들어진 DB 보정)다. **둘 다 고친다** — init.sql 만 고치면 서버 DB 에 반영되지 않고, + 마이그레이션만 쓰면 새로 세운 DB 에 그 변경이 없다. 적용: `scripts/migrate.py` - npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번. `npm run dev:frontend` / `dev:admin` / `dev:site` - 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/.py` @@ -260,7 +262,9 @@ docker compose logs -f solution-worker - **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf` (후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다) - DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`). - 스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다 + 스키마는 `postgres-init/init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/` + (이미 만들어진 DB 보정)다. **둘 다 고친다** — init.sql 만 고치면 서버 DB 에 반영되지 않고, + 마이그레이션만 쓰면 새로 세운 DB 에 그 변경이 없다. 적용: `scripts/migrate.py` - npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번. `npm run dev:frontend` / `dev:admin` / `dev:site` - 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/.py` diff --git a/admin/frontend/src/pages/LocalContentPage.tsx b/admin/frontend/src/pages/LocalContentPage.tsx index d0f5965..8f53c55 100644 --- a/admin/frontend/src/pages/LocalContentPage.tsx +++ b/admin/frontend/src/pages/LocalContentPage.tsx @@ -9,9 +9,13 @@ import {customFetch} from '@/api/mutator/custom-fetch'; import {toast} from 'sonner'; type Status = 1 | 2 | 3; +// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로 +// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather). +type ContentType = 2 | 3 | 4; type LocalContent = { id: string; + contentType: ContentType; title: string; region: string; period: string; @@ -24,10 +28,15 @@ type LocalContent = { }; const STATUS_LABEL: Record = {1: '검수 대기', 2: '발행됨', 3: '종료됨'}; +const TYPE_LABEL: Record = {2: '축제', 3: '관광지', 4: '맛집'}; +const TYPE_BADGE_VARIANT: Record = { + 2: 'accent', 3: 'default', 4: 'outline', +}; type ApiContent = { local_content_id: string; region_code: string; + content_type: ContentType; external_id?: string; title?: string; body: Record; @@ -43,14 +52,17 @@ function formatDate(value: unknown) { } function toContent(item: ApiContent): LocalContent { + // 기간은 축제만 있다(eventstartdate/eventenddate) — 관광지·맛집은 상시 정보라 비어 있는 게 정상이다. const start = formatDate(item.body.eventstartdate); const end = formatDate(item.body.eventenddate); + const period = [start, end].filter(Boolean).join(' ~ ') || (item.content_type === 2 ? '기간 미정' : '상시'); return { id: item.local_content_id, + contentType: item.content_type, title: item.title || '제목 없음', region: String(item.body.addr1 || item.region_code), - period: [start, end].filter(Boolean).join(' ~ ') || '기간 미정', - source: `공공데이터포털 전국문화축제표준데이터 · ${item.external_id ?? '-'}`, + period, + source: `한국관광공사 TourAPI · ${item.external_id ?? '-'}`, status: item.status, selected: false, displayStart: item.display_start_at, @@ -95,15 +107,24 @@ export function LocalContentPage() { } catch { toast.error('발행하지 못했습니다.'); } }; const sync = async () => { - const regionCode = window.prompt('내부 지역 코드(예: gunsan)를 입력하세요.', 'gunsan')?.trim(); - if (!regionCode) return; + // ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다. + // 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다. + const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim(); + if (!placeId) return; setSyncing(true); try { - const res = await customFetch<{result?: {success?: boolean}; msg?: string; collected?: number; skipped?: number}>({ - url: '/v1/admin/local-content/sync-festivals', method: 'POST', data: {region_code: regionCode}, - }); + const res = await customFetch<{ + result?: {success?: boolean}; msg?: string; + festivals?: number; attractions?: number; restaurants?: number; changed?: boolean; + }>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'}); if (res.result?.success === false) throw new Error(res.msg); - toast.success(`${res.collected ?? 0}건 수집 · ${res.skipped ?? 0}건 중복 제외`); + // ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다. + const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`; + if (!res.changed) { + toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`); + } else { + toast.success(`${summary} 반영 — 다음 빌드부터 발행본에 실립니다.`); + } await load(); } catch (error) { toast.error(error instanceof Error ? error.message : '공공데이터 수집에 실패했습니다.'); @@ -138,7 +159,7 @@ export function LocalContentPage() { return ( {syncing ? '수집 중…' : '공공데이터 수집'}} >
@@ -174,6 +195,7 @@ export function LocalContentPage() {

{item.title}

+ {TYPE_LABEL[item.contentType]} {STATUS_LABEL[item.status]}
diff --git a/docker-compose.yml b/docker-compose.yml index f361e00..dc314d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,9 @@ services: SCHEDULER_ENABLED: "1" volumes: - ./solution/site/payloads:/app/out/payloads + # ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고, + # 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다. + - ./postgres-init:/app/postgres-init:ro ports: - "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800" extra_hosts: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4de16a1..37e9033 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -87,7 +87,7 @@ | 레이어 구조 | 원본 그대로 — `router` → `service` → `crud`, 람다 DB 실행, `Req_*`/`Res_*` 프로토콜, `RemoveNoneResponse` | "기존 컨벤션을 그대로 따른다" | | 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 | | DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 | -| 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 | +| 마이그레이션 | Alembic 안 씀. `init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/NNNN_*.sql`(기존 DB 보정), 적용기 `scripts/migrate.py` | 2026-08-31 에 누적 ALTER 를 없애며 "운영 DB 가 생기는 순간 다시 필요해진다" 고 적어 뒀다. **2026-09-09 그 순간이 왔다** — init.sql 은 DB 를 처음 만들 때만 도는데 서버·로컬에 이미 데이터가 있어서, `local.place_contents` 테이블과 `places.external_category` 컬럼이 실제 DB 에만 빠져 있었다. TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고 화면에는 "그냥 안 나오는 것"으로만 보였다. Alembic 을 안 쓰는 이유는 그대로다 — ORM·init.sql 두 곳에 스키마가 있고 `test_schema_ddl.py` 가 대조하는 구조라, 세 번째 정의를 더하면 어긋날 자리가 하나 더 생긴다 | | 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 | | 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 | | `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 | diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index c850ece..894870a 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS place.places ( latitude NUMERIC(10,7) NULL, -- 위도 longitude NUMERIC(10,7) NULL, -- 경도 region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드 — ★ 지역정보 캐시 키 + external_category VARCHAR(200) NULL, -- 외부 장소 DB 분류 원문("음식점 > 한식 > 육류" / "펜션") — 주변 맛집 경쟁업소 제외 기준(폴백) verified_at TIMESTAMPTZ NULL, -- ★ 동일 업소 검증 통과 시각. NULL = 수집·발행 금지 verified_by uuid NULL, -- 검증자(company.users.user_id) content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각 — 개별 재빌드 대상 판별용 @@ -211,6 +212,73 @@ CREATE TABLE IF NOT EXISTS local.local_contents ( deleted BOOLEAN NOT NULL DEFAULT FALSE ); +-- 업장 반경의 주변 정보(맛집·관광지·축제·여행코스). ★ 키는 place_id — local_contents(행정구역 캐시)와 다르다. +-- 빌드 때마다 TourAPI locationBasedList2 로 갱신. 응답에서 사라진 행은 소프트 삭제, hidden 은 유지. +CREATE TABLE IF NOT EXISTS local.place_contents ( + place_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 주변 정보 식별자(PK) + place_id uuid NOT NULL, -- 사업장(place.places.place_id) + content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집 5=여행코스 + external_id VARCHAR(100) NOT NULL, -- TourAPI contentid + title VARCHAR(300) NOT NULL, + body JSONB NOT NULL, -- 정규화한 TourAPI 항목(좌표·주소·사진·기간) + distance_m INTEGER NOT NULL, -- 업장 좌표에서의 거리(m). 정렬 기준 + has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무 + display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다 + hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음) + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +-- 축제·관광지·맛집 **실체**. 전국 공용 — 같은 장소를 업장마다 복제하지 않는다. +-- ★ place_contents 는 키가 (place_id, …) 라 업장마다 TourAPI 응답을 통째로 복제했다. +-- 실측(2026-09-09): 업장 한 곳에 144행. 열 곳이면 같은 축제가 열 벌이다. +-- 실체는 여기 한 행, 업장별로 다른 것(거리·숨김)만 place_spots 에 남긴다. +CREATE TABLE IF NOT EXISTS local.spots ( + spot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 장소 식별자(PK) + source SMALLINT NOT NULL, -- 출처(LocalSource): 2=TourAPI + external_id VARCHAR(100) NOT NULL, -- TourAPI contentid + content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집 + title VARCHAR(300) NOT NULL, + body JSONB NOT NULL, -- 정규화한 원본(주소·사진·기간·분류) + latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 — 거리 계산이 읽는다 + longitude NUMERIC(10,7) NULL, + region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드(지역 단위 조회) + has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무 + display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다 + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +-- 업장 ↔ 주변 장소. 업장별로 다른 것은 거리와 숨김뿐이다. +CREATE TABLE IF NOT EXISTS local.place_spots ( + place_id uuid NOT NULL, -- 사업장(place.places.place_id) + spot_id uuid NOT NULL, -- 장소(local.spots.spot_id) + distance_m INTEGER NOT NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값 + hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (place_id, spot_id) +); + +-- 지역 이야기(가요·인물·연표·엽서·퀴즈). ★ 업장이 아니라 **지역**의 것이다 — +-- 군산 이야기는 군산 숙소가 같이 쓴다. 검수 전에는 발행에 나가지 않는다(status). +CREATE TABLE IF NOT EXISTS local.region_stories ( + region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 이야기 식별자(PK) + region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드 + kind VARCHAR(50) NOT NULL, -- 'songs' 'people' 'chronicle' 'postcard' 'quiz' + data JSONB NOT NULL, + source_type SMALLINT NOT NULL DEFAULT 4, -- 출처(SourceType): 4=llm 1=운영자 + status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + CREATE TABLE IF NOT EXISTS local.routes ( route_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 경로 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) @@ -265,6 +333,24 @@ CREATE TABLE IF NOT EXISTS site.sites ( deleted BOOLEAN NOT NULL DEFAULT FALSE ); +-- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다. +-- sites.theme 은 색·서체·섹션 순서/on-off 만 갖고, 내용은 여기로 나온다. +-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 콘텐츠가 39,645 B(94%)였다. +-- 크기가 아니라 쓰기 단위가 문제였다 — 영상 주소 하나를 고쳐도 42 KB 를 다시 썼다. +CREATE TABLE IF NOT EXISTS site.site_contents ( + site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 콘텐츠 식별자(PK) + site_id uuid NOT NULL, -- 사이트(site.sites.site_id) + section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' … + data JSONB NOT NULL, -- 그 섹션의 항목 배열(shared 의 XxxItem[]) + source_type SMALLINT NOT NULL DEFAULT 1, -- 출처(SourceType): 1=owner 2=api 4=llm + shared_ref uuid NULL, -- ★ 공유 원본(local.region_stories 등)을 가리킬 때. 값을 복제하지 않는다 + status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus): 3·4 만 노출 + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + CREATE TABLE IF NOT EXISTS site.site_versions ( site_version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 버전 식별자(PK) site_id uuid NOT NULL, -- 사이트(site.sites.site_id) @@ -379,7 +465,15 @@ CREATE INDEX IF NOT EXISTS idx_facts_publishable ON fact.facts (place_id, status -- local CREATE INDEX IF NOT EXISTS idx_local_contents_region ON local.local_contents (region_code); CREATE INDEX IF NOT EXISTS idx_routes_place ON local.routes (place_id); +CREATE INDEX IF NOT EXISTS idx_place_contents_place ON local.place_contents (place_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_place_contents_keyed ON local.place_contents (place_id, content_type, external_id) WHERE deleted = false; CREATE INDEX IF NOT EXISTS idx_nearby_links_place ON local.nearby_links (place_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_spots_external ON local.spots (source, external_id) WHERE deleted = FALSE; +CREATE INDEX IF NOT EXISTS idx_spots_region ON local.spots (region_code, content_type); +CREATE INDEX IF NOT EXISTS idx_place_spots_place ON local.place_spots (place_id) WHERE deleted = FALSE; +CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind ON local.region_stories (region_code, kind) WHERE deleted = FALSE; +CREATE INDEX IF NOT EXISTS idx_site_contents_site ON site.site_contents (site_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section ON site.site_contents (site_id, section_id) WHERE deleted = FALSE; -- 지역 캐시 중복 방지. external_id 가 있는 항목(축제·관광지·맛집)과 없는 항목(날씨)을 나눠 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_keyed ON local.local_contents (region_code, content_type, external_id) diff --git a/postgres-init/migrations/0001_place_contents_external_category.sql b/postgres-init/migrations/0001_place_contents_external_category.sql new file mode 100644 index 0000000..2f7f144 --- /dev/null +++ b/postgres-init/migrations/0001_place_contents_external_category.sql @@ -0,0 +1,30 @@ +-- 0001 · 업장 반경 주변정보 테이블 + 외부 분류 컬럼 +-- +-- init.sql 에는 둘 다 있는데 그 파일은 DB 를 처음 만들 때만 돈다. 이미 만들어진 DB +-- (로컬·서버)에는 없어서, TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없었다 — +-- 축제·맛집이 0건이었고 화면에는 "그냥 안 나오는 것"으로만 보였다(실측 2026-09-09). + +CREATE TABLE IF NOT EXISTS local.place_contents ( + place_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + place_id uuid NOT NULL, + content_type SMALLINT NOT NULL, -- LocalContentType: 2=축제 3=관광지 4=맛집 5=여행코스 + external_id VARCHAR(100) NOT NULL, -- TourAPI contentid + title VARCHAR(300) NOT NULL, + body JSONB NOT NULL, -- 정규화한 TourAPI 항목(좌표·주소·사진·기간) + distance_m INTEGER NOT NULL, -- 업장 좌표에서의 거리(m). 정렬 기준 + has_image BOOLEAN NOT NULL DEFAULT FALSE, + display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다 + hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음) + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_place_contents_place ON local.place_contents (place_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_place_contents_keyed + ON local.place_contents (place_id, content_type, external_id) WHERE deleted = FALSE; + +-- 주변 맛집에서 경쟁 업소를 빼는 기준(폴백). 이 컬럼이 없으면 places 조회 자체가 +-- UndefinedColumnError 로 죽어 빌드가 "사업장을 찾을 수 없다"로 끝난다. +ALTER TABLE place.places ADD COLUMN IF NOT EXISTS external_category VARCHAR(200) NULL; diff --git a/postgres-init/migrations/0002_spots_shared.sql b/postgres-init/migrations/0002_spots_shared.sql new file mode 100644 index 0000000..60f0ea7 --- /dev/null +++ b/postgres-init/migrations/0002_spots_shared.sql @@ -0,0 +1,76 @@ +-- 0002 · 주변 정보를 **실체와 관계로** 가른다 +-- +-- 지금 `local.place_contents` 는 키가 (place_id, content_type, external_id) 라서 +-- 업장마다 TourAPI 응답을 통째로 복제한다. 축제 그 자체(실체)와 "우리 업장에서 850m" +-- (관계)가 한 행에 섞여 있어서 그렇다. +-- 실측(2026-09-09, 조이모텔): 한 곳에 144행. 성남 중원구에 모텔이 10곳 들어오면 +-- 같은 축제·같은 맛집이 10벌이 된다 — TourAPI 가 준 값은 글자 하나까지 같고 거리만 다르다. +-- +-- 실체는 `spots` 에 external_id 로 한 행만 두고, 업장별로 다른 것(거리·숨김)만 +-- `place_spots` 에 남긴다. 원본을 한 번 갱신하면 그 장소를 참조하는 사이트 전부에 반영된다. + +-- 실체 — 축제·관광지·맛집. 전국 공용. +CREATE TABLE IF NOT EXISTS local.spots ( + spot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + source SMALLINT NOT NULL, -- LocalSource: 2=TourAPI + external_id VARCHAR(100) NOT NULL, -- TourAPI contentid + content_type SMALLINT NOT NULL, -- LocalContentType: 2=축제 3=관광지 4=맛집 + title VARCHAR(300) NOT NULL, + body JSONB NOT NULL, -- 정규화한 원본(주소·사진·기간·분류) + latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 둔다 — 거리 계산이 이걸 읽는다 + longitude NUMERIC(10,7) NULL, + region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드(지역 단위 조회용) + has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무 + display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다 + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +-- ★ 같은 장소를 두 번 담지 않는 유일성. 이 인덱스가 곧 "공유"의 근거다. +CREATE UNIQUE INDEX IF NOT EXISTS uq_spots_external + ON local.spots (source, external_id) WHERE deleted = FALSE; +CREATE INDEX IF NOT EXISTS idx_spots_region ON local.spots (region_code, content_type); + +-- 관계 — 이 업장에서 얼마나 먼가. 업장별로 다른 건 이것뿐이다. +CREATE TABLE IF NOT EXISTS local.place_spots ( + place_id uuid NOT NULL, -- place.places.place_id + spot_id uuid NOT NULL, -- local.spots.spot_id + distance_m INTEGER NOT NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값 + hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 사장님·운영자가 뺀 것. 재수집이 덮어쓰지 않는다 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (place_id, spot_id) +); + +CREATE INDEX IF NOT EXISTS idx_place_spots_place ON local.place_spots (place_id) WHERE deleted = FALSE; + +-- 이미 place_contents 에 쌓인 것을 옮긴다. 같은 external_id 는 한 행으로 접힌다. +-- ★ ON CONFLICT 대신 NOT EXISTS 로 쓴다 — 부분 유니크 인덱스(WHERE deleted=FALSE)는 +-- ON CONFLICT 의 추론 대상이 되지만, 조건까지 적어야 해서 읽기 어렵다. +INSERT INTO local.spots (source, external_id, content_type, title, body, + latitude, longitude, has_image, display_end_at, collected_at) +SELECT DISTINCT ON (pc.external_id) + 2, pc.external_id, pc.content_type, pc.title, pc.body, + NULLIF(pc.body->>'mapy', '')::NUMERIC, + NULLIF(pc.body->>'mapx', '')::NUMERIC, + pc.has_image, pc.display_end_at, pc.collected_at +FROM local.place_contents pc +WHERE pc.deleted = FALSE + AND NOT EXISTS ( + SELECT 1 FROM local.spots s + WHERE s.source = 2 AND s.external_id = pc.external_id AND s.deleted = FALSE + ) +ORDER BY pc.external_id, pc.collected_at DESC; + +INSERT INTO local.place_spots (place_id, spot_id, distance_m, hidden) +SELECT pc.place_id, s.spot_id, pc.distance_m, pc.hidden +FROM local.place_contents pc +JOIN local.spots s ON s.source = 2 AND s.external_id = pc.external_id AND s.deleted = FALSE +WHERE pc.deleted = FALSE +ON CONFLICT (place_id, spot_id) DO NOTHING; + +-- ★ place_contents 는 지우지 않는다. 읽는 쪽이 새 테이블로 다 옮겨간 뒤에 별도 번호로 뗀다 — +-- 한 번에 갈아엎으면 되돌릴 자리가 없다. diff --git a/postgres-init/migrations/0003_site_contents.sql b/postgres-init/migrations/0003_site_contents.sql new file mode 100644 index 0000000..b081699 --- /dev/null +++ b/postgres-init/migrations/0003_site_contents.sql @@ -0,0 +1,75 @@ +-- 0003 · 섹션 콘텐츠를 `sites.theme` 에서 꺼내 행으로 +-- +-- 지금은 색·서체(디자인)와 섹션별 콘텐츠가 `site.sites.theme` JSONB 한 칸에 같이 있다. +-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 +-- 나머지 41,500 B 가 섹션이다. 그중 콘텐츠(sections[].data)만 39,645 B — 94%. +-- 가장 큰 섹션 하나(itinerary)가 17,990 B 로, 디자인 전체의 28 배다. +-- +-- 크기가 문제인 게 아니다(JSONB 는 1GB 까지 든다). 문제는 **쓰기 단위**다: +-- · 사장님이 영상 주소 하나(592 B)를 고쳐도 42 KB 를 통째로 다시 쓴다 +-- · 같은 사이트를 둘이 만지면 나중 쓰기가 앞을 통째로 덮는다 +-- · 항목마다 "누가 넣었나 · 확인됐나"를 물을 자리가 없다(facts 는 status 를 갖는다) +-- · 검증이 없어 렌더러에 존재하지도 않는 섹션이 남는다 +-- (실측: 조이모텔에 course·schedule — 켤 수는 있는데 화면엔 아무 일도 안 일어난다) +-- +-- ★ 이 테이블이 JSON import/export 의 단위다. 사이트 하나 = 이 행 묶음 + theme(디자인). + +CREATE TABLE IF NOT EXISTS site.site_contents ( + site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + site_id uuid NOT NULL, -- site.sites.site_id + section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' … + data JSONB NOT NULL, -- 그 섹션의 항목 배열(shared 의 XxxItem[] 계약) + source_type SMALLINT NOT NULL DEFAULT 1, -- SourceType: 1=owner 2=api 3=crawl 4=llm + -- ★ 공유 콘텐츠는 값을 복제하지 않고 **id 로 가리킨다**(축제·지역 이야기). + -- 가리키는 동안 data 는 비어 있을 수 있다 — 발행할 때 원본을 펼쳐 payload 에 싣는다. + shared_ref uuid NULL, + status SMALLINT NOT NULL DEFAULT 1, -- FactStatus 와 같은 축: 3=verified 4=corrected 만 노출 + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +-- 한 사이트의 한 섹션은 한 행이다. 순서·on/off 는 theme 이 갖는다. +CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section + ON site.site_contents (site_id, section_id) WHERE deleted = FALSE; + +-- 지역 이야기 — 가요·인물·연표·엽서·퀴즈는 **업장이 아니라 지역의 것**이다. +-- 군산 이야기는 군산 숙소가 같이 쓴다. 지금은 만들 자리가 아예 없어서 +-- 시안(/s/stay)에는 사람이 3만 자를 손으로 넣었다. +CREATE TABLE IF NOT EXISTS local.region_stories ( + region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드 + kind VARCHAR(50) NOT NULL, -- 'songs' 'people' 'chronicle' 'postcard' 'quiz' + data JSONB NOT NULL, + source_type SMALLINT NOT NULL DEFAULT 4, -- 4=llm 1=운영자 + status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 검수 전에는 안 나간다 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind + ON local.region_stories (region_code, kind) WHERE deleted = FALSE; + +-- 이미 theme 에 박혀 있는 콘텐츠를 옮긴다. +-- ★ data 는 문자열로 저장돼 있다(서버가 파싱하지 않는 규약) — JSONB 로 되돌린다. +-- 깨진 JSON 은 옮기지 않는다. 원본은 theme 에 남으므로 잃지 않는다. +INSERT INTO site.site_contents (site_id, section_id, data, source_type, status, sort_order) +SELECT s.site_id, + sec->>'id', + (sec->>'data')::JSONB, + 1, -- 사장님이 넣은 것으로 본다 + 3, -- 이미 발행에 쓰이던 값이라 verified + ordinality - 1 +FROM site.sites s +CROSS JOIN LATERAL jsonb_array_elements(s.theme->'sections') WITH ORDINALITY AS t(sec, ordinality) +WHERE s.deleted = FALSE + AND s.theme ? 'sections' + AND sec->>'data' IS NOT NULL + AND sec->>'data' <> '' + AND jsonb_typeof((sec->>'data')::JSONB) IS NOT NULL +ON CONFLICT DO NOTHING; + +-- ★ theme.sections[].data 는 지우지 않는다. 읽는 쪽(site_payload._theme)이 새 테이블을 +-- 먼저 보고 없을 때만 theme 으로 떨어지게 한 뒤, 별도 번호로 뗀다. diff --git a/postgres-init/migrations/0004_local_contents_unify.sql b/postgres-init/migrations/0004_local_contents_unify.sql new file mode 100644 index 0000000..46df64d --- /dev/null +++ b/postgres-init/migrations/0004_local_contents_unify.sql @@ -0,0 +1,102 @@ +-- 0004 · 공용 콘텐츠를 **한 테이블로** 되돌린다 (0002·0003 정정) +-- +-- 0002 에서 `local.spots` 를, 0003 에서 `local.region_stories` 를 새로 팠다. 그게 틀렸다. +-- 풀어야 했던 문제는 "`place_contents` 가 place_id 키라 업장마다 복제된다" 하나였고, +-- 그건 **실체와 관계를 가르면** 끝나는 문제였다. 그런데 거기에 더해 테이블까지 새로 만들어서 +-- 같은 성격의 공용 콘텐츠가 세 곳으로 갈라졌다 — +-- local_contents 에 이미 축제 60 · 관광지 12 · 맛집 12건이 들어 있었다(실측 2026-09-09). +-- `local_contents` 는 처음부터 content_type 으로 종류를 가르는 설계였다. 그걸 쓰면 됐다. +-- +-- 그래서 공용 콘텐츠는 `local.local_contents` 한 벌로 모으고, +-- 업장과의 관계(거리·숨김)만 `local.place_contents` 에 남긴다. 관계까지 합치면 복제가 돌아온다. + +-- ── 1. 공용 테이블에 부족했던 것 ────────────────────────────────────────── +-- 좌표: 거리 계산과 일정 조립이 읽는다. body 안에 두면 행마다 JSON 을 파싱해야 한다. +ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS latitude NUMERIC(10,7) NULL; +ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS longitude NUMERIC(10,7) NULL; +-- 종류를 문자열로도 받는다 — 지역 이야기(songs·people·chronicle·postcard·quiz)는 +-- SMALLINT 코드를 새로 발급하기보다 이름 그대로가 읽힌다. 장소류는 NULL. +ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS kind VARCHAR(50) NULL; + +-- ★ region_code 를 nullable 로 푼다. +-- 축제·관광지·맛집은 **전국 공용**이다 — 같은 축제가 시군구마다 한 행씩 생기면 +-- "한 벌"이 아니다. 지역은 조회 편의로 채우되 유일성의 근거는 external_id 다. +ALTER TABLE local.local_contents ALTER COLUMN region_code DROP NOT NULL; + +-- 옛 유일성: (region_code, content_type, external_id) — 같은 축제를 지역 수만큼 허용한다. +DROP INDEX IF EXISTS local.uq_local_contents_keyed; + +-- ★ 그 인덱스가 허용해 온 중복을 먼저 접는다. 실측(2026-09-09): 축제 60행이 지역 2곳에 +-- 걸쳐 있었다 — 같은 행사가 시군구마다 한 행씩이다. 가장 최근에 수집한 것만 남긴다. +-- (접지 않고 유일 인덱스를 걸면 "could not create unique index" 로 통째로 실패한다.) +UPDATE local.local_contents lc + SET deleted = TRUE, updated_at = now() + WHERE lc.deleted = FALSE + AND lc.external_id IS NOT NULL + AND lc.local_content_id NOT IN ( + SELECT DISTINCT ON (source, external_id) local_content_id + FROM local.local_contents + WHERE deleted = FALSE AND external_id IS NOT NULL + ORDER BY source, external_id, collected_at DESC, created_at DESC + ); +-- 새 유일성: 출처가 준 id 하나면 한 행. 지역과 무관하다. +CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_external + ON local.local_contents (source, external_id) + WHERE deleted = FALSE AND external_id IS NOT NULL; +-- 지역 이야기: 한 지역에 종류당 한 벌. +CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_kind + ON local.local_contents (region_code, kind) + WHERE deleted = FALSE AND kind IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_local_contents_type_region + ON local.local_contents (content_type, region_code) WHERE deleted = FALSE; + +-- ── 2. spots 에 있던 실체를 공용 테이블로 접어 넣는다 ───────────────────── +INSERT INTO local.local_contents (region_code, content_type, source, external_id, title, body, + latitude, longitude, display_end_at, collected_at, status) +SELECT s.region_code, s.content_type, s.source, s.external_id, s.title, s.body, + s.latitude, s.longitude, s.display_end_at, s.collected_at, + 2 -- 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정) +FROM local.spots s +WHERE s.deleted = FALSE + AND NOT EXISTS ( + SELECT 1 FROM local.local_contents lc + WHERE lc.deleted = FALSE AND lc.external_id = s.external_id AND lc.source = s.source + ); + +-- ── 3. 업장 ↔ 콘텐츠 관계 ──────────────────────────────────────────────── +-- 기존 place_contents 는 값을 통째로 들고 있던 옛 구조다. 이름을 비켜 두고 새로 만든다. +ALTER TABLE IF EXISTS local.place_contents RENAME TO place_contents_legacy; +ALTER INDEX IF EXISTS local.uq_place_contents_keyed RENAME TO uq_place_contents_legacy_keyed; +ALTER INDEX IF EXISTS local.idx_place_contents_place RENAME TO idx_place_contents_legacy_place; + +CREATE TABLE IF NOT EXISTS local.place_contents ( + place_id uuid NOT NULL, -- 사업장(place.places.place_id) + local_content_id uuid NOT NULL, -- 공용 콘텐츠(local.local_contents) + distance_m INTEGER NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값 + hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (place_id, local_content_id) +); +CREATE INDEX IF NOT EXISTS idx_place_contents_place + ON local.place_contents (place_id) WHERE deleted = FALSE; + +-- place_spots(0002) 의 관계를 옮긴다. spot → 같은 external_id 의 공용 콘텐츠로 잇는다. +INSERT INTO local.place_contents (place_id, local_content_id, distance_m, hidden) +SELECT ps.place_id, lc.local_content_id, ps.distance_m, ps.hidden +FROM local.place_spots ps +JOIN local.spots s ON s.spot_id = ps.spot_id AND s.deleted = FALSE +JOIN local.local_contents lc + ON lc.source = s.source AND lc.external_id = s.external_id AND lc.deleted = FALSE +WHERE ps.deleted = FALSE +ON CONFLICT (place_id, local_content_id) DO NOTHING; + +-- ── 4. 갈라놓았던 테이블을 접는다 ──────────────────────────────────────── +-- 아직 읽는 코드가 없다(0002·0003 은 테이블만 만들었다) — 지금이 지우기 가장 싸다. +DROP TABLE IF EXISTS local.place_spots; +DROP TABLE IF EXISTS local.spots; +DROP TABLE IF EXISTS local.region_stories; + +-- ★ place_contents_legacy 는 남긴다. 읽는 코드가 새 구조로 옮겨간 뒤 별도 번호로 뗀다 — +-- 한 번에 갈아엎으면 되돌릴 자리가 없다. diff --git a/postgres-init/migrations/0005_flatten_schemas.sql b/postgres-init/migrations/0005_flatten_schemas.sql new file mode 100644 index 0000000..1d949f0 --- /dev/null +++ b/postgres-init/migrations/0005_flatten_schemas.sql @@ -0,0 +1,85 @@ +-- 0005 · 스키마를 해체하고 이름으로 소속을 말한다 +-- +-- ★ 왜 (2026-09-09) +-- 스키마를 나누는 이유는 셋뿐이다 — ① 같은 이름을 여러 번 쓰려고 ② 권한을 덩어리로 주려고 +-- ③ 읽는 사람에게 경계를 보여주려고. 이 프로젝트에선 ①②가 성립하지 않는다: +-- · `web4ai_db` 는 이 제품 전용 database 다. 다른 프로젝트는 다른 database 에 있고, +-- 19개 테이블 이름이 전부 다르다 — 충돌할 상대가 애초에 없다. +-- · 접속 계정이 하나다. 스키마별 GRANT 를 준 적이 없고, 실제 권한 분리는 DB 밖에서 +-- 포트로 한다(솔루션 9800 / 어드민 9801, ADMIN_API_BIND=127.0.0.1). +-- ③만 남는데, 그 값을 치르는 방식이 틀렸다: +-- · 19개 중 9개가 스키마 이름을 다시 말한다(place.places · fact.facts · site.sites · +-- local.local_contents · job.jobs …). 폴더 이름이 파일 이름에 이미 들어 있으면 +-- 그 폴더는 정보를 더하지 못한다. +-- · **결정적으로, 경계가 조인 방향과 반대다.** `places.place_id` 를 참조하는 테이블이 +-- 11개인데 그중 `place` 스키마 안에 있는 건 **하나도 없다**. 관계를 잇는 컬럼이 +-- 매번 경계를 넘는다면 그건 경계가 아니다. 특히 업장↔지역 관계 테이블은 +-- 존재 자체가 두 스키마를 잇는 것이라 어디에 둬도 틀린다. +-- +-- 그래서 스키마를 없애고 **접두어가 소속을 말한다.** +-- place_* 한 업장의 것 · site_* 한 사이트의 것 · users/jobs 아무에게도 안 속함 +-- area_contents 만 접두어가 없다 — **그게 공유물이라는 표시다.** +-- 이 규칙이 서면 오늘 같은 복제 사고는 이름만 봐도 걸린다(place_ 붙은 테이블에 축제가 +-- 들어가 있으면 그 자체로 틀린 것이다). +-- +-- ★ 데이터는 옮기지 않는다. RENAME 뿐이라 잃는 행이 없다. + +-- ── 사람 · 큐 (아무에게도 안 속한다) ────────────────────────────────────── +ALTER TABLE IF EXISTS company.users SET SCHEMA public; +ALTER TABLE IF EXISTS job.jobs SET SCHEMA public; + +-- ── 업장 ──────────────────────────────────────────────────────────────── +ALTER TABLE IF EXISTS place.places SET SCHEMA public; +ALTER TABLE IF EXISTS place.place_links SET SCHEMA public; +ALTER TABLE IF EXISTS place.units SET SCHEMA public; +ALTER TABLE IF EXISTS place.media SET SCHEMA public; +ALTER TABLE IF EXISTS fact.facts SET SCHEMA public; +ALTER TABLE IF EXISTS fact.faqs SET SCHEMA public; + +-- 이름이 무엇에 속하는지, 그리고 무엇을 담는지 말하게 한다. +ALTER TABLE IF EXISTS public.place_links RENAME TO place_channels; -- 네이버·야놀자 등 채널 +ALTER TABLE IF EXISTS public.units RENAME TO place_units; -- 객실·메뉴·프로그램 +ALTER TABLE IF EXISTS public.media RENAME TO place_photos; -- 실제로 사진만 담는다 +ALTER TABLE IF EXISTS public.facts RENAME TO place_facts; +ALTER TABLE IF EXISTS public.faqs RENAME TO place_faqs; + +-- ── 지역 (여럿이 나눠 쓴다) ────────────────────────────────────────────── +ALTER TABLE IF EXISTS local.local_contents SET SCHEMA public; +ALTER TABLE IF EXISTS local.place_contents SET SCHEMA public; +-- 접두어 없는 유일한 테이블. 공유물이라는 표시다. +ALTER TABLE IF EXISTS public.local_contents RENAME TO area_contents; +-- 값이 아니라 **관계**다 — 이름이 그걸 말해야 한다. +ALTER TABLE IF EXISTS public.place_contents RENAME TO place_area_refs; + +-- ── 사이트 ────────────────────────────────────────────────────────────── +ALTER TABLE IF EXISTS site.sites SET SCHEMA public; +ALTER TABLE IF EXISTS site.site_contents SET SCHEMA public; +ALTER TABLE IF EXISTS site.site_versions SET SCHEMA public; +ALTER TABLE IF EXISTS site.publish_logs SET SCHEMA public; +-- 섹션의 '내용'이다. site_contents 는 무엇의 콘텐츠인지 말하지 않는다. +ALTER TABLE IF EXISTS public.site_contents RENAME TO site_sections; +ALTER TABLE IF EXISTS public.publish_logs RENAME TO site_publish_logs; + +-- ── 아직 남기는 것 ────────────────────────────────────────────────────── +-- 빈 테이블(place_aliases · routes · nearby_links · ai_check_results)과 이관 잔재 +-- (place_contents_legacy)는 0006 에서 판단한다. 한 파일에 두 가지 일을 넣지 않는다. +ALTER TABLE IF EXISTS place.place_aliases SET SCHEMA public; +ALTER TABLE IF EXISTS local.routes SET SCHEMA public; +ALTER TABLE IF EXISTS local.nearby_links SET SCHEMA public; +ALTER TABLE IF EXISTS site.ai_check_results SET SCHEMA public; +ALTER TABLE IF EXISTS local.place_contents_legacy SET SCHEMA public; +ALTER TABLE IF EXISTS public.routes RENAME TO place_routes; +ALTER TABLE IF EXISTS public.place_aliases RENAME TO place_names; +-- ai_check_results 는 place_id 가 키다 — 사이트가 아니라 **업장**이 AI 검색에 나오는지를 +-- 재는 것이라, 사이트를 안 만들어도 잴 수 있다. site_ 가 아니라 place_ 다. +ALTER TABLE IF EXISTS public.ai_check_results RENAME TO place_ai_checks; + +-- ── 빈 스키마 정리 ────────────────────────────────────────────────────── +-- ★ CASCADE 를 쓰지 않는다. 옮기다 빠뜨린 테이블이 있으면 여기서 실패해야 한다 — +-- CASCADE 면 그 테이블이 조용히 함께 지워진다. +DROP SCHEMA IF EXISTS company RESTRICT; +DROP SCHEMA IF EXISTS place RESTRICT; +DROP SCHEMA IF EXISTS fact RESTRICT; +DROP SCHEMA IF EXISTS local RESTRICT; +DROP SCHEMA IF EXISTS site RESTRICT; +DROP SCHEMA IF EXISTS job RESTRICT; diff --git a/postgres-init/migrations/0006_prune_unused.sql b/postgres-init/migrations/0006_prune_unused.sql new file mode 100644 index 0000000..25fd606 --- /dev/null +++ b/postgres-init/migrations/0006_prune_unused.sql @@ -0,0 +1,27 @@ +-- 0006 · 한 번도 쓰지 않은 테이블과 이관 잔재를 뗀다 +-- +-- 실측(2026-09-09) — 네 테이블이 **행 0건**이다. 만들어 두고 기능이 붙지 않았다: +-- place_names(별칭) · place_routes(가는 길) · nearby_links(주변) · place_ai_checks(AI 노출 점검) +-- 화면에 나오지 않는 테이블을 유지비만 내며 들고 있을 이유가 없다 — 필요해지는 날 +-- 그때의 요구에 맞춰 만드는 편이 낫다(지금 모양이 그때 맞을 거라는 보장도 없다). +-- +-- ★ nearby_links 는 area_contents 와 역할이 겹친다. 둘 다 "업장 주변의 장소"인데 +-- 하나는 카카오 로컬, 하나는 TourAPI 였다. 겹치는 자리를 남겨 두면 다음 사람이 +-- "어느 쪽이 진짜냐"를 다시 묻는다 — 오늘 spots/local_contents 로 이미 겪었다. + +DROP TABLE IF EXISTS public.place_names; +DROP TABLE IF EXISTS public.place_routes; +DROP TABLE IF EXISTS public.nearby_links; +DROP TABLE IF EXISTS public.place_ai_checks; + +-- 0004 에서 place_contents 를 관계 테이블로 바꾸며 옆으로 밀어 둔 옛 구조. +-- 값은 area_contents(198건) + place_area_refs(144건)로 전부 옮겨졌다. +DROP TABLE IF EXISTS public.place_contents_legacy; + +-- ── 발행 이력은 합치지 않는다 ────────────────────────────────────────── +-- 처음엔 site_versions(52) 와 site_publish_logs(52) 가 1:1 이라 합치려 했다. 아니다. +-- 실측: action 분포가 publish 45 · rebuild 7 로 이미 섞여 있다. 같은 버전을 다시 굽거나 +-- 내리면 그 순간 2:1 이 된다 — 지금 1:1 인 건 우연이고 **구조는 1:N** 이다. +-- 합쳤다면 두 번째 발행이 첫 기록을 덮었을 것이다. +-- 성격도 다르다: site_versions 는 "무엇을 구웠나"(스냅샷·JSON-LD), publish_logs 는 +-- "언제 무슨 일이 있었나"(동작·결과·반려사유·실행자)다. diff --git a/postgres-init/migrations/README.md b/postgres-init/migrations/README.md new file mode 100644 index 0000000..56b06e1 --- /dev/null +++ b/postgres-init/migrations/README.md @@ -0,0 +1,34 @@ +# 마이그레이션 — 이미 만들어진 DB 를 따라오게 하는 파일 + +`init-data/init.sql` 은 **새 DB 를 세우는 전체 DDL** 이고 계속 최신을 유지한다. +여기 파일들은 **이미 데이터가 든 DB** 를 그 최신으로 끌어올린다. 둘 다 필요하다. + +## 왜 생겼나 (2026-09-09) + +`DECISIONS.md` 는 누적 ALTER 를 없애면서 이렇게 적어 뒀다 — +*"아직 git·서버 어디에도 안 올라가 보정할 기존 DB 가 없다 … **운영 DB 가 생기는 순간 +다시 필요해진다**"*. 그 순간이 왔다. + +실제로 터졌다: 로컬 DB 에 `local.place_contents` 테이블과 `place.places.external_category` +컬럼이 없었다. `init.sql` 에는 둘 다 있었지만 그 파일은 **DB 를 처음 만들 때만** 돈다. +TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, +화면에는 "그냥 안 나오는 것"으로 보였다 — 원인을 짚는 데 한참 걸렸다. + +## 규칙 + +- 파일명 `NNNN_한글_요약.sql` — 번호는 이어 붙인다. 지운 번호를 재사용하지 않는다. +- **재실행 안전하게 쓴다**(`IF NOT EXISTS` · `ADD COLUMN IF NOT EXISTS`). + 적용 기록이 있어도 사람이 손으로 한 번 더 돌릴 수 있다. +- 한 파일 = 한 가지 변경. 여러 테이블을 건드려도 목적이 하나면 한 파일이다. +- **`init.sql` 도 같이 고친다.** 새 DB 는 그 파일만 읽는다 — 여기만 고치면 + 새로 세운 DB 에 그 변경이 없다(`tests/test_schema_ddl.py` 가 ORM 과의 어긋남은 잡지만, + init.sql 과 이 폴더의 어긋남은 아무도 안 잡는다). + +## 적용 + +```bash +cd solution/backend && .venv/bin/python scripts/migrate.py # 안 돌린 것만 +cd solution/backend && .venv/bin/python scripts/migrate.py --dry-run # 목록만 +``` + +적용 기록은 `public.schema_migrations` 에 남는다. 이미 있는 번호는 건너뛴다. diff --git a/solution/backend/common/category_schema/resources/lodging.json b/solution/backend/common/category_schema/resources/lodging.json index 61991af..30a5895 100644 --- a/solution/backend/common/category_schema/resources/lodging.json +++ b/solution/backend/common/category_schema/resources/lodging.json @@ -19,6 +19,10 @@ { "key": "breakfast", "label": "조식 제공", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false }, { "key": "baby_amenities", "label": "유아용품 비치", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false }, { "key": "pickup_service", "label": "픽업 서비스", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false }, + { "key": "facilities", "label": "부대시설", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false }, + { "key": "total_rooms", "label": "객실 수", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "실" }, + { "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" }, + { "key": "building_scale", "label": "규모", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false }, { "key": "intro", "label": "숙소 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true }, { "key": "room_type", "label": "객실 타입", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false }, @@ -29,6 +33,15 @@ { "key": "bathroom_count", "label": "욕실 수", "type": "number", "scope": "unit", "required": false, "critical": false, "allow_llm": false, "unit": "개" }, { "key": "has_kitchen", "label": "주방 여부", "type": "bool", "scope": "unit", "required": false, "critical": true, "allow_llm": false }, { "key": "has_aircon", "label": "에어컨", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_bathroom", "label": "욕실", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_tv", "label": "TV", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_internet", "label": "인터넷", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_refrigerator", "label": "냉장고", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_hairdryer", "label": "드라이기", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_toiletries", "label": "세면도구", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_table", "label": "테이블", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_sofa", "label": "소파", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_home_theater", "label": "홈시어터", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, { "key": "view", "label": "전망", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, { "key": "weekday_price", "label": "주중 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" }, { "key": "weekend_price", "label": "주말 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" }, diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index db6384a..fcfb8a5 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -12,6 +12,7 @@ from common.enums import ( UserRole, PlaceStatus, FactStatus, + SourceType, MediaStatus, SiteStatus, BuildStatus, @@ -46,7 +47,6 @@ class MainTableMixin(_DBTypeMixin): # ERD 도메인 모델 class users(MainTableMixin, MAIN_BASE): __tablename__ = "users" - __table_args__ = {"schema": "company"} user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) # 20자였다. 구글 계정의 로그인 아이디를 `google_`(최대 28자)로 만들면서 넓혔다 — @@ -78,12 +78,11 @@ class places(MainTableMixin, MAIN_BASE): __tablename__ = "places" __table_args__ = ( Index("idx_places_region_code", "region_code", postgresql_where=text("deleted = false")), - {"schema": "place"}, ) place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) # ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다. - owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(company.users) + owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users) name = Column(String(200), nullable=False) # 상호명(입력값) category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키 status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value) @@ -99,6 +98,10 @@ class places(MainTableMixin, MAIN_BASE): latitude = Column(Numeric(10, 7), nullable=True) longitude = Column(Numeric(10, 7), nullable=True) region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회) + # 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다. + # ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고, + # 이 값은 그 폴백이다(services/local_content_service._own_food_class). + external_category = Column(String(200), nullable=True) verified_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미검증 → 수집·발행 금지 verified_by = Column(UUID(as_uuid=True), nullable=True) # ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 — @@ -106,25 +109,14 @@ class places(MainTableMixin, MAIN_BASE): content_updated_at = Column(DateTime(timezone=True), nullable=True) -class place_aliases(MainTableMixin, MAIN_BASE): - """상호 별칭. 동명 업소 구분과 재검색에 쓴다(옛 상호, '본관/별관' 표기 등).""" - __tablename__ = "place_aliases" - __table_args__ = {"schema": "place"} - - alias_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - place_id = Column(UUID(as_uuid=True), nullable=False, index=True) - alias = Column(String(200), nullable=False) - source_type = Column(SmallInteger, nullable=False) # SourceType - - -class place_links(MainTableMixin, MAIN_BASE): +class place_channels(MainTableMixin, MAIN_BASE): """Perplexity 가 발견한 채널 URL. ★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다. raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다.""" - __tablename__ = "place_links" + __tablename__ = "place_channels" __table_args__ = ( Index( "uq_place_links_place_url", @@ -133,7 +125,6 @@ class place_links(MainTableMixin, MAIN_BASE): unique=True, postgresql_where=text("deleted = false"), ), - {"schema": "place"}, ) link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -148,12 +139,11 @@ class place_links(MainTableMixin, MAIN_BASE): raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results) -class units(MainTableMixin, MAIN_BASE): +class place_units(MainTableMixin, MAIN_BASE): """업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램. 가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다.""" - __tablename__ = "units" - __table_args__ = {"schema": "place"} + __tablename__ = "place_units" unit_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) place_id = Column(UUID(as_uuid=True), nullable=False, index=True) @@ -161,15 +151,14 @@ class units(MainTableMixin, MAIN_BASE): sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0) -class media(MainTableMixin, MAIN_BASE): +class place_photos(MainTableMixin, MAIN_BASE): """사진. Gemini Vision 이 분류 라벨과 alt 를 만든다. ★ source_type 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2), 결론에 따라 발행 시 source_type 으로 걸러낼 수 있어야 한다. ★ vision_confidence 가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다.""" - __tablename__ = "media" - __table_args__ = {"schema": "place"} + __tablename__ = "place_photos" media_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) place_id = Column(UUID(as_uuid=True), nullable=False, index=True) @@ -190,7 +179,7 @@ class media(MainTableMixin, MAIN_BASE): # ============================================================ # fact : 사실 / FAQ # ============================================================ -class facts(MainTableMixin, MAIN_BASE): +class place_facts(MainTableMixin, MAIN_BASE): """★ 가장 중요한 테이블. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다. - key 는 업종 스키마(common/category_schema)에 정의된 것만 허용한다. @@ -200,7 +189,7 @@ class facts(MainTableMixin, MAIN_BASE): 활성 유니크: 같은 (place, unit, key) 로 살아있는 fact 는 1건. REJECTED/EXPIRED 는 이력으로 남기므로 제외한다.""" - __tablename__ = "facts" + __tablename__ = "place_facts" __table_args__ = ( # unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다. # 노출값은 (사업장, 단위, key) 당 1건. 후보(1,2)·이력(5,6)은 제외 — 재수집이 쌓일 수 있게. @@ -234,7 +223,6 @@ class facts(MainTableMixin, MAIN_BASE): "status", postgresql_where=text("deleted = false AND status IN (3, 4)"), ), - {"schema": "fact"}, ) fact_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -246,17 +234,16 @@ class facts(MainTableMixin, MAIN_BASE): source_type = Column(SmallInteger, nullable=False) # SourceType — owner | api | crawl | llm source_url = Column(String(1000), nullable=True) collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) - verified_by = Column(UUID(as_uuid=True), nullable=True) # company.users.user_id + verified_by = Column(UUID(as_uuid=True), nullable=True) # users.user_id verified_at = Column(DateTime(timezone=True), nullable=True) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value) expires_at = Column(DateTime(timezone=True), nullable=True) # 지나면 EXPIRED 전이 대상 -class faqs(MainTableMixin, MAIN_BASE): +class place_faqs(MainTableMixin, MAIN_BASE): """FAQ. ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다.""" - __tablename__ = "faqs" - __table_args__ = {"schema": "fact"} + __tablename__ = "place_faqs" faq_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) place_id = Column(UUID(as_uuid=True), nullable=False, index=True) @@ -271,13 +258,13 @@ class faqs(MainTableMixin, MAIN_BASE): # ============================================================ # local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변 # ============================================================ -class local_contents(MainTableMixin, MAIN_BASE): +class area_contents(MainTableMixin, MAIN_BASE): """지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 — 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다. ★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다.""" - __tablename__ = "local_contents" + __tablename__ = "area_contents" __table_args__ = ( # external_id 가 있는 항목(축제·관광지·맛집)은 출처 고유 ID 로 중복을 막는다. Index( @@ -296,7 +283,6 @@ class local_contents(MainTableMixin, MAIN_BASE): unique=True, postgresql_where=text("deleted = false AND external_id IS NULL"), ), - {"schema": "local"}, ) local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -313,49 +299,32 @@ class local_contents(MainTableMixin, MAIN_BASE): display_end_at = Column(DateTime(timezone=True), nullable=True) collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지) - - -class routes(MainTableMixin, MAIN_BASE): - """가는 길. 검증 상태(FactStatus)를 그대로 쓴다 — 틀린 경로 안내도 헛걸음을 만든다.""" - - __tablename__ = "routes" - __table_args__ = {"schema": "local"} - - route_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - place_id = Column(UUID(as_uuid=True), nullable=False, index=True) - origin_name = Column(String(200), nullable=False) # 출발지 (예: "서울역") - transport = Column(SmallInteger, nullable=False) # TransportType - duration_min = Column(Integer, nullable=True) - distance_m = Column(Integer, nullable=True) - description = Column(Text, nullable=True) - source_type = Column(SmallInteger, nullable=False) # SourceType - status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value) - sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0) - - -class nearby_links(MainTableMixin, MAIN_BASE): - """주변 맛집·시설. 카카오 카테고리 검색 결과를 사업장에 붙인 것. - 카테고리 검색은 좌표 변환보다 4배 비싸므로 region_code 단위 캐시(local_contents)에서 파생시킨다.""" - - __tablename__ = "nearby_links" - __table_args__ = {"schema": "local"} - - nearby_link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - place_id = Column(UUID(as_uuid=True), nullable=False, index=True) - region_code = Column(String(10), nullable=True) # 어느 지역 캐시에서 왔는지 - name = Column(String(200), nullable=False) - category_name = Column(String(100), nullable=True) # 카카오 category_name - kakao_place_id = Column(String(32), nullable=True) - distance_m = Column(Integer, nullable=True) - url = Column(String(1000), nullable=True) + # ★ 0004 에서 늘렸다. 좌표는 body 안에도 있지만 거리 계산이 행마다 JSON 을 펴야 해서 꺼냈다. latitude = Column(Numeric(10, 7), nullable=True) longitude = Column(Numeric(10, 7), nullable=True) - sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0) + # 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류. 장소류는 NULL. + kind = Column(String(50), nullable=True) + + +class place_area_refs(MainTableMixin, MAIN_BASE): + """업장 ↔ 지역 콘텐츠. **업장별로 다른 것은 거리와 숨김뿐이다.** + + ★ 예전에는 이 테이블이 값을 통째로 들고 있었다(place_contents). 키가 place_id 라서 + 업장마다 TourAPI 응답이 복제됐다 — 실측(2026-09-09) 조이모텔 한 곳에 144행이고, + 성남 중원구에 모텔이 열 곳 들어오면 같은 축제가 열 벌이 된다. + 실체는 `area_contents` 에 한 행, 여기에는 관계만 남긴다. + ★ hidden 은 재수집이 덮어쓰지 않는다 — 운영자가 뺀 것을 다음 갱신이 되살리면 + 숨긴 의미가 없다.""" + + __tablename__ = "place_area_refs" + + place_id = Column(UUID(as_uuid=True), primary_key=True) # places.place_id + local_content_id = Column(UUID(as_uuid=True), primary_key=True) # area_contents.local_content_id + distance_m = Column(Integer, nullable=True) # 정렬·도보 시간의 원값 + hidden = Column(Boolean, nullable=False, server_default=text("false"), default=False) + -# ============================================================ -# site : 사이트 / 버전 / 발행 로그 / AI 노출 점검 -# ============================================================ class sites(MainTableMixin, MAIN_BASE): """발행 대상 사이트. 사업장당 1개. ★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다.""" @@ -364,7 +333,6 @@ class sites(MainTableMixin, MAIN_BASE): __table_args__ = ( Index("uq_sites_place", "place_id", unique=True, postgresql_where=text("deleted = false")), Index("uq_sites_domain", "domain", unique=True, postgresql_where=text("deleted = false AND domain IS NOT NULL")), - {"schema": "site"}, ) site_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -383,13 +351,46 @@ class sites(MainTableMixin, MAIN_BASE): # NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다(services/site_payload). theme = Column(JSONB, nullable=True) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value) - current_version_id = Column(UUID(as_uuid=True), nullable=True) # site.site_versions.site_version_id + current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id published_at = Column(DateTime(timezone=True), nullable=True) # 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을 # 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다. thumbnail_url = Column(String(500), nullable=True) +class site_sections(MainTableMixin, MAIN_BASE): + """섹션 하나의 콘텐츠. **JSON import/export 의 단위**다. + + ★ 왜 theme 에서 꺼냈나 (2026-09-09) + 색·서체(디자인)와 섹션 콘텐츠가 `sites.theme` JSONB 한 칸에 같이 있었다. + 실측(/s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%)다. + 크기가 문제가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도 + 42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다 + "누가 넣었나 · 확인됐나"를 물을 자리가 없었다. + ★ 순서·on/off·배리에이션은 여전히 theme 이 갖는다. 여기는 **내용만** 든다. + ★ shared_ref 가 있으면 값을 복제하지 않고 원본(region_stories 등)을 가리킨다 — + 발행할 때 펼쳐 payload 에 싣는다.""" + + __tablename__ = "site_sections" + __table_args__ = ( + Index( + "uq_site_contents_section", + "site_id", "section_id", + unique=True, + postgresql_where=text("deleted = false"), + ), + ) + + site_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + site_id = Column(UUID(as_uuid=True), nullable=False, index=True) + section_id = Column(String(50), nullable=False) # 'songs' 'itinerary' 'video' 'people' … + data = Column(JSONB, nullable=False) # shared 의 XxxItem[] 계약 + source_type = Column(SmallInteger, nullable=False, server_default=text("1"), default=SourceType.OWNER.value) + shared_ref = Column(UUID(as_uuid=True), nullable=True) # 공유 원본을 가리킬 때 + status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value) + sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0) + + class site_versions(MainTableMixin, MAIN_BASE): """빌드 버전. ★ 정적 빌드 — snapshot 에 빌드 시점 데이터를 박제하고, 방문자는 DB 와 만나지 않는다. ★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다. @@ -399,7 +400,6 @@ class site_versions(MainTableMixin, MAIN_BASE): __tablename__ = "site_versions" __table_args__ = ( Index("uq_site_versions_no", "site_id", "version", unique=True, postgresql_where=text("deleted = false")), - {"schema": "site"}, ) site_version_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -413,11 +413,10 @@ class site_versions(MainTableMixin, MAIN_BASE): built_at = Column(DateTime(timezone=True), nullable=True) -class publish_logs(MainTableMixin, MAIN_BASE): +class site_publish_logs(MainTableMixin, MAIN_BASE): """발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다.""" - __tablename__ = "publish_logs" - __table_args__ = {"schema": "site"} + __tablename__ = "site_publish_logs" publish_log_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) site_id = Column(UUID(as_uuid=True), nullable=False, index=True) @@ -429,27 +428,7 @@ class publish_logs(MainTableMixin, MAIN_BASE): actor_user_id = Column(UUID(as_uuid=True), nullable=True) -class ai_check_results(MainTableMixin, MAIN_BASE): - """AI 검색 노출 점검. 이 서비스의 목표 지표 — - AI 가 이 가게를 **우리 사이트를 근거로** 설명하는가, 아니면 여전히 OTA 를 인용하는가.""" - __tablename__ = "ai_check_results" - __table_args__ = {"schema": "site"} - - ai_check_result_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - place_id = Column(UUID(as_uuid=True), nullable=False, index=True) - engine = Column(SmallInteger, nullable=False) # AiEngine - query = Column(String(500), nullable=False) # 던진 질의 - answer = Column(Text, nullable=True) - cited_urls = Column(JSONB, nullable=True) # 인용된 URL 목록 - is_own_site_cited = Column(Boolean, nullable=False, server_default=text("false"), default=False) # ★ 핵심 지표 - ota_cited = Column(Boolean, nullable=False, server_default=text("false"), default=False) # OTA 가 대신 인용됐는지 - checked_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) - - -# ============================================================ -# job : 작업 큐 (PostgreSQL 을 큐로 — LPS 의 job 큐 구조 이식) -# ============================================================ class jobs(MainTableMixin, MAIN_BASE): """작업 큐. 수집·비전분석·빌드는 몇 분 걸려 동기 요청으로 처리할 수 없다. @@ -477,7 +456,6 @@ class jobs(MainTableMixin, MAIN_BASE): unique=True, postgresql_where=text("status IN (1, 2) AND dedupe_key IS NOT NULL"), ), - {"schema": "job"}, ) # ★ 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라 diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index d0b1cb1..bc24e18 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -291,10 +291,12 @@ VISION_AUTO_APPROVE_CONFIDENCE = 0.7 class LocalContentType(CodeEnum): """local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류.""" - WEATHER = 1 # 날씨 (Open-Meteo) - FESTIVAL = 2 # 축제 (TourAPI 행사정보, 주 1회) - ATTRACTION = 3 # 관광지 (TourAPI 지역기반, 월 1회) - RESTAURANT = 4 # 주변 맛집 (카카오 카테고리 검색) + WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시) + # ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25 + FESTIVAL = 2 # 축제/공연/행사 (15) + ATTRACTION = 3 # 관광지 (12) + RESTAURANT = 4 # 음식점 (39) + COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다 class LocalSource(CodeEnum): diff --git a/solution/backend/common/utils/geo.py b/solution/backend/common/utils/geo.py new file mode 100644 index 0000000..94630c4 --- /dev/null +++ b/solution/backend/common/utils/geo.py @@ -0,0 +1,24 @@ +"""좌표 거리 — 공용 한 벌. + +★ 같은 하버사인 공식이 tour_lookup(500m 동일업소 판정)·itinerary(일정 반경)·tour_api(축제 20km 필터) + 세 곳에 각각 복사돼 있었다(2026-09-08 정리). 지구 반지름·단위가 파일마다 달라지면 같은 두 점의 + 거리가 모듈마다 다르게 나온다 — 거리로 무엇을 넣고 뺄지 정하는 코드가 셋이라 한 벌이어야 한다. + +국내 범위라 하버사인(구면 근사)이면 충분하다. 오차는 수 m 수준으로, 우리가 쓰는 판정 +(500m 이내·5~20km 반경)에서 결과를 바꾸지 않는다. +""" +import math + +EARTH_RADIUS_M = 6_371_000.0 + + +def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """두 좌표(위도, 경도) 사이의 거리(m). ★ 인자 순서는 (위도, 경도) — mapx/mapy 는 (경도, 위도)라 뒤집어 넣는다.""" + p1, p2 = math.radians(lat1), math.radians(lat2) + dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a)) + + +def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + return haversine_m(lat1, lng1, lat2, lng2) / 1000.0 diff --git a/solution/backend/crud/fact_crud.py b/solution/backend/crud/fact_crud.py index 3b5b1dd..f055940 100644 --- a/solution/backend/crud/fact_crud.py +++ b/solution/backend/crud/fact_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import facts +from common.database.model.models import place_facts from common.enums import ErrorType, FactStatus from common.logger import LOG from common.utils.gtime import GTime @@ -21,17 +21,17 @@ _ACTIVE = _PUBLISHED + _CANDIDATE def _unit_cond(unit_id): """unit_id 는 NULL 비교라 == 로 걸면 안 된다(사업장 단위 fact 를 못 찾는다).""" - return facts.unit_id.is_(None) if unit_id is None else facts.unit_id == unit_id + return place_facts.unit_id.is_(None) if unit_id is None else place_facts.unit_id == unit_id # fact CRUD. 항상 place_id 로 스코프한다. class IFactCRUD(ABC): @abstractmethod - async def add_fact(self, cdb: AsyncSession, fact: facts) -> ErrorType: + async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType: pass @abstractmethod - async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, facts]: + async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, place_facts]: pass @abstractmethod @@ -39,11 +39,11 @@ class IFactCRUD(ABC): pass @abstractmethod - async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, facts]: + async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]: pass @abstractmethod - async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, facts]: + async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]: pass @abstractmethod @@ -68,18 +68,18 @@ class IFactCRUD(ABC): class FactCRUD(IFactCRUD): - async def add_fact(self, cdb: AsyncSession, fact: facts) -> ErrorType: + async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType: try: return await DB_SESSION_MNG.insert(cdb, fact) except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED - async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, facts]: + async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, place_facts]: try: query = ( - select(facts) - .where(facts.fact_id == fact_id, facts.place_id == place_id, facts.deleted == False) # noqa: E712 + select(place_facts) + .where(place_facts.fact_id == fact_id, place_facts.place_id == place_id, place_facts.deleted == False) # noqa: E712 .limit(1) ) err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) @@ -101,19 +101,19 @@ class FactCRUD(IFactCRUD): active_only=True → REJECTED·EXPIRED 이력 제외 (관리 화면 기본: 노출값 + 후보) """ try: - conditions = [facts.place_id == place_id, facts.deleted == False] # noqa: E712 + conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712 if unit_id is not None: - conditions.append(facts.unit_id == unit_id) + conditions.append(place_facts.unit_id == unit_id) if publishable_only: - conditions.append(facts.status.in_(_PUBLISHED)) + conditions.append(place_facts.status.in_(_PUBLISHED)) elif status is not None: - conditions.append(facts.status == status) + conditions.append(place_facts.status == status) elif active_only: - conditions.append(facts.status.in_(_ACTIVE)) + conditions.append(place_facts.status.in_(_ACTIVE)) # 노출값이 먼저, 그 아래 후보. 같은 key 끼리 붙어 보이게 정렬한다. - query = select(facts).where(and_(*conditions)).order_by( - facts.key.asc(), facts.status.desc(), facts.collected_at.desc() + query = select(place_facts).where(and_(*conditions)).order_by( + place_facts.key.asc(), place_facts.status.desc(), place_facts.collected_at.desc() ) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) return (err_type, list(rows) if err_type == ErrorType.SUCCESS else []) @@ -121,17 +121,17 @@ class FactCRUD(IFactCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] - async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, facts]: + async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]: """★ 지금 사이트에 나가고 있는 값. 없으면 (SUCCESS, None). 유니크 인덱스가 1건만 허용하므로 결과는 0 또는 1건이다.""" try: query = ( - select(facts) + select(place_facts) .where(and_( - facts.place_id == place_id, - facts.key == key, - facts.deleted == False, # noqa: E712 - facts.status.in_(_PUBLISHED), + place_facts.place_id == place_id, + place_facts.key == key, + place_facts.deleted == False, # noqa: E712 + place_facts.status.in_(_PUBLISHED), _unit_cond(unit_id), )) .limit(1) @@ -144,20 +144,20 @@ class FactCRUD(IFactCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None - async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, facts]: + async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]: """같은 출처가 이미 올려둔 후보. 재수집이 같은 후보를 계속 쌓지 않도록 갱신 대상을 찾는다.""" try: query = ( - select(facts) + select(place_facts) .where(and_( - facts.place_id == place_id, - facts.key == key, - facts.source_type == source_type, - facts.deleted == False, # noqa: E712 - facts.status.in_(_CANDIDATE), + place_facts.place_id == place_id, + place_facts.key == key, + place_facts.source_type == source_type, + place_facts.deleted == False, # noqa: E712 + place_facts.status.in_(_CANDIDATE), _unit_cond(unit_id), )) - .order_by(facts.collected_at.desc()) + .order_by(place_facts.collected_at.desc()) .limit(1) ) err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) @@ -176,7 +176,7 @@ class FactCRUD(IFactCRUD): values = {"collected_at": ts, "updated_at": ts} if source_url: values["source_url"] = source_url - query = update(facts).where(facts.fact_id == fact_id, facts.deleted == False).values(**values) # noqa: E712 + query = update(place_facts).where(place_facts.fact_id == fact_id, place_facts.deleted == False).values(**values) # noqa: E712 return await DB_SESSION_MNG.add_with_rowcount(cdb, query) except Exception as ex: LOG.e_no_callstack(ex) @@ -186,8 +186,8 @@ class FactCRUD(IFactCRUD): """기존 후보를 새 수집값으로 갱신. 같은 출처의 후보가 계속 쌓이는 것을 막는다.""" try: query = ( - update(facts) - .where(facts.fact_id == fact_id, facts.status.in_(_CANDIDATE), facts.deleted == False) # noqa: E712 + update(place_facts) + .where(place_facts.fact_id == fact_id, place_facts.status.in_(_CANDIDATE), place_facts.deleted == False) # noqa: E712 .values(value=value, source_url=source_url, status=status, collected_at=ts, updated_at=ts) ) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) @@ -202,11 +202,11 @@ class FactCRUD(IFactCRUD): 허용 전이 판정 자체는 service 가 FACT_STATUS_TRANSITIONS 로 먼저 한다.""" try: query = ( - update(facts) + update(place_facts) .where( - facts.fact_id == fact_id, - facts.status.in_(tuple(from_statuses)), - facts.deleted == False, # noqa: E712 + place_facts.fact_id == fact_id, + place_facts.status.in_(tuple(from_statuses)), + place_facts.deleted == False, # noqa: E712 ) .values(status=to_status, updated_at=GTime.UTC(), **data) ) @@ -221,15 +221,15 @@ class FactCRUD(IFactCRUD): 지우지 않고 이력으로 남긴다 — 예전에 뭐가 나갔는지 추적할 수 있어야 한다.""" try: conditions = [ - facts.place_id == place_id, - facts.key == key, - facts.deleted == False, # noqa: E712 - facts.status.in_(_PUBLISHED), + place_facts.place_id == place_id, + place_facts.key == key, + place_facts.deleted == False, # noqa: E712 + place_facts.status.in_(_PUBLISHED), _unit_cond(unit_id), ] if except_fact_id is not None: - conditions.append(facts.fact_id != except_fact_id) - query = update(facts).where(and_(*conditions)).values(status=FactStatus.EXPIRED.value, updated_at=ts) + conditions.append(place_facts.fact_id != except_fact_id) + query = update(place_facts).where(and_(*conditions)).values(status=FactStatus.EXPIRED.value, updated_at=ts) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) except Exception as ex: LOG.e_no_callstack(ex) @@ -241,15 +241,15 @@ class FactCRUD(IFactCRUD): 후보를 그대로 두면 사람 확인 큐에 이미 처리된 항목이 계속 남는다.""" try: conditions = [ - facts.place_id == place_id, - facts.key == key, - facts.deleted == False, # noqa: E712 - facts.status.in_(_CANDIDATE), + place_facts.place_id == place_id, + place_facts.key == key, + place_facts.deleted == False, # noqa: E712 + place_facts.status.in_(_CANDIDATE), _unit_cond(unit_id), ] if except_fact_id is not None: - conditions.append(facts.fact_id != except_fact_id) - query = update(facts).where(and_(*conditions)).values(status=FactStatus.REJECTED.value, updated_at=ts) + conditions.append(place_facts.fact_id != except_fact_id) + query = update(place_facts).where(and_(*conditions)).values(status=FactStatus.REJECTED.value, updated_at=ts) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) except Exception as ex: LOG.e_no_callstack(ex) diff --git a/solution/backend/crud/faq_crud.py b/solution/backend/crud/faq_crud.py index 831fae0..02a456a 100644 --- a/solution/backend/crud/faq_crud.py +++ b/solution/backend/crud/faq_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import faqs +from common.database.model.models import place_faqs from common.enums import ErrorType, FactStatus from common.logger import LOG from common.utils.gtime import GTime @@ -21,7 +21,7 @@ class IFaqCRUD(ABC): pass @abstractmethod - async def add_faq(self, cdb: AsyncSession, faq: faqs) -> ErrorType: + async def add_faq(self, cdb: AsyncSession, faq: place_faqs) -> ErrorType: pass @abstractmethod @@ -33,26 +33,26 @@ class IFaqCRUD(ABC): pass @abstractmethod - async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, faqs]: + async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, place_faqs]: pass class FaqCRUD(IFaqCRUD): async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool = False) -> Tuple[ErrorType, list]: try: - conds = [faqs.place_id == place_id, faqs.deleted == False] # noqa: E712 - conds.append(faqs.status.in_(_PUBLISHABLE if publishable_only else _ACTIVE)) - query = select(faqs).where(and_(*conds)).order_by(faqs.sort_order.asc(), faqs.created_at.asc()) + conds = [place_faqs.place_id == place_id, place_faqs.deleted == False] # noqa: E712 + conds.append(place_faqs.status.in_(_PUBLISHABLE if publishable_only else _ACTIVE)) + query = select(place_faqs).where(and_(*conds)).order_by(place_faqs.sort_order.asc(), place_faqs.created_at.asc()) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) return (err_type, list(rows) if err_type == ErrorType.SUCCESS else []) except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] - async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, faqs]: + async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, place_faqs]: try: - query = select(faqs).where( - faqs.faq_id == faq_id, faqs.place_id == place_id, faqs.deleted == False # noqa: E712 + query = select(place_faqs).where( + place_faqs.faq_id == faq_id, place_faqs.place_id == place_id, place_faqs.deleted == False # noqa: E712 ).limit(1) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: @@ -64,7 +64,7 @@ class FaqCRUD(IFaqCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None - async def add_faq(self, cdb: AsyncSession, faq: faqs) -> ErrorType: + async def add_faq(self, cdb: AsyncSession, faq: place_faqs) -> ErrorType: try: return await DB_SESSION_MNG.insert(cdb, faq) except Exception as ex: @@ -78,11 +78,11 @@ class FaqCRUD(IFaqCRUD): 재생성이 사람의 판단을 덮어쓰면 fact 쪽 규칙과 어긋난다.""" try: query = ( - update(faqs) + update(place_faqs) .where( - faqs.place_id == place_id, - faqs.deleted == False, # noqa: E712 - faqs.status.in_((FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)), + place_faqs.place_id == place_id, + place_faqs.deleted == False, # noqa: E712 + place_faqs.status.in_((FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)), ) .values(status=FactStatus.EXPIRED.value, updated_at=ts) ) @@ -94,8 +94,8 @@ class FaqCRUD(IFaqCRUD): async def transition(self, cdb: AsyncSession, faq_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]: try: query = ( - update(faqs) - .where(faqs.faq_id == faq_id, faqs.status.in_(tuple(from_statuses)), faqs.deleted == False) # noqa: E712 + update(place_faqs) + .where(place_faqs.faq_id == faq_id, place_faqs.status.in_(tuple(from_statuses)), place_faqs.deleted == False) # noqa: E712 .values(status=to_status, updated_at=GTime.UTC(), **data) ) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) diff --git a/solution/backend/crud/local_content_crud.py b/solution/backend/crud/local_content_crud.py index 5d88970..c65c3ad 100644 --- a/solution/backend/crud/local_content_crud.py +++ b/solution/backend/crud/local_content_crud.py @@ -2,73 +2,97 @@ from sqlalchemy import and_, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import local_contents +from common.database.model.models import area_contents from common.enums import ErrorType, LocalContentType from common.utils.gtime import GTime class LocalContentCRUD: async def list(self, db, status: int | None = None, region_code: str | None = None): - conds = [local_contents.deleted == False, local_contents.content_type == LocalContentType.FESTIVAL.value] # noqa: E712 + """축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은 + 관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨).""" + conds = [area_contents.deleted == False] # noqa: E712 if status is not None: - conds.append(local_contents.status == status) + conds.append(area_contents.status == status) if region_code: - conds.append(local_contents.region_code == region_code) + conds.append(area_contents.region_code == region_code) return await DB_SESSION_MNG.execute( - db, select(local_contents).where(and_(*conds)).order_by(local_contents.collected_at.desc()) + db, select(area_contents).where(and_(*conds)).order_by(area_contents.collected_at.desc()) ) async def insert(self, db, row): return await DB_SESSION_MNG.insert(db, row) - async def get_by_external_id(self, db, region_code: str, external_id: str): - err, rows = await DB_SESSION_MNG.execute( - db, - select(local_contents).where( - local_contents.region_code == region_code, - local_contents.content_type == LocalContentType.FESTIVAL.value, - local_contents.external_id == external_id, - local_contents.deleted == False, # noqa: E712 - ).limit(1), - ) - return err, rows[0] if rows else None - async def publish(self, db, ids: list, user_id): return await DB_SESSION_MNG.add_with_rowcount( db, - update(local_contents).where( - local_contents.local_content_id.in_(ids), local_contents.deleted == False # noqa: E712 + update(area_contents).where( + area_contents.local_content_id.in_(ids), area_contents.deleted == False # noqa: E712 ).values(status=2, published_at=GTime.UTC(), published_by=user_id, updated_at=GTime.UTC()), ) async def update(self, db, content_id, data: dict): return await DB_SESSION_MNG.add_with_rowcount( db, - update(local_contents).where( - local_contents.local_content_id == content_id, local_contents.deleted == False # noqa: E712 + update(area_contents).where( + area_contents.local_content_id == content_id, area_contents.deleted == False # noqa: E712 ).values(**data, updated_at=GTime.UTC()), ) async def end(self, db, content_id): return await self.update(db, content_id, {"status": 3}) + async def list_keyed(self, db, region_code: str, content_type: int): + """지역 × 종류의 외부 ID 있는 행 전부(축제·관광지·맛집). 동기화가 기존값과 비교할 때 쓴다.""" + return await DB_SESSION_MNG.execute( + db, + select(area_contents).where( + area_contents.region_code == region_code, + area_contents.content_type == content_type, + area_contents.external_id.isnot(None), + area_contents.deleted == False, # noqa: E712 + ), + ) + + async def upsert_keyed(self, db, values: dict): + """외부 ID 로 식별되는 행(축제·관광지·맛집)의 삽입/갱신. + + ★ uq_local_contents_keyed 부분 유니크 인덱스에 태운다 — 같은 지역을 두 번 동기화해도 + 중복 행이 생기지 않고 기존 값만 갱신된다.""" + stmt = pg_insert(area_contents).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=[area_contents.region_code, area_contents.content_type, area_contents.external_id], + index_where=and_(area_contents.deleted == False, area_contents.external_id.isnot(None)), # noqa: E712 + set_={ + "title": stmt.excluded.title, + "body": stmt.excluded.body, + "source": stmt.excluded.source, + "status": stmt.excluded.status, + "collected_at": stmt.excluded.collected_at, + "display_end_at": stmt.excluded.display_end_at, + "published_at": stmt.excluded.published_at, + "updated_at": GTime.UTC(), + }, + ) + return await DB_SESSION_MNG.add(db, stmt) + async def get_weather(self, db, region_code: str): err, rows = await DB_SESSION_MNG.execute( db, - select(local_contents).where( - local_contents.region_code == region_code, - local_contents.content_type == LocalContentType.WEATHER.value, - local_contents.external_id.is_(None), - local_contents.deleted == False, # noqa: E712 + select(area_contents).where( + area_contents.region_code == region_code, + area_contents.content_type == LocalContentType.WEATHER.value, + area_contents.external_id.is_(None), + area_contents.deleted == False, # noqa: E712 ).limit(1), ) return err, rows[0] if rows else None async def upsert_weather(self, db, values: dict): - stmt = pg_insert(local_contents).values(**values) + stmt = pg_insert(area_contents).values(**values) stmt = stmt.on_conflict_do_update( - index_elements=[local_contents.region_code, local_contents.content_type], - index_where=and_(local_contents.deleted == False, local_contents.external_id.is_(None)), # noqa: E712 + index_elements=[area_contents.region_code, area_contents.content_type], + index_where=and_(area_contents.deleted == False, area_contents.external_id.is_(None)), # noqa: E712 set_={ "source": stmt.excluded.source, "body": stmt.excluded.body, diff --git a/solution/backend/crud/media_crud.py b/solution/backend/crud/media_crud.py index 63b796e..7f0a7d2 100644 --- a/solution/backend/crud/media_crud.py +++ b/solution/backend/crud/media_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import media +from common.database.model.models import place_photos from common.enums import ErrorType, MediaStatus from common.logger import LOG from common.utils.gtime import GTime @@ -45,20 +45,20 @@ class MediaCRUD(IMediaCRUD): ★ alt_required=True 는 status 필터와 짝으로만 쓴다 — alt 가 빈 사진은 빌더가 렌더하지 않으므로(services/snapshot.py), '발행되면 실릴 것'을 물었을 때 승인만 보면 답이 틀린다.""" try: - conditions = [media.place_id == place_id, media.deleted == False] # noqa: E712 + conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712 if status is not None: - conditions.append(media.status == status) + conditions.append(place_photos.status == status) if unlabeled_only: conditions.append( - or_(media.alt_text.is_(None), func.btrim(media.alt_text) == "") + or_(place_photos.alt_text.is_(None), func.btrim(place_photos.alt_text) == "") ) if unit_id is not None: - conditions.append(media.unit_id == unit_id) + conditions.append(place_photos.unit_id == unit_id) if alt_required: # 공백만 있는 alt 도 빌더에선 '없음'이다 — 같은 기준으로 거른다. - conditions.append(media.alt_text.is_not(None)) - conditions.append(func.btrim(media.alt_text) != "") - query = select(media).where(and_(*conditions)).order_by(media.sort_order.asc(), media.created_at.asc()) + conditions.append(place_photos.alt_text.is_not(None)) + conditions.append(func.btrim(place_photos.alt_text) != "") + query = select(place_photos).where(and_(*conditions)).order_by(place_photos.sort_order.asc(), place_photos.created_at.asc()) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) return (err_type, list(rows) if err_type == ErrorType.SUCCESS else []) except Exception as ex: @@ -72,8 +72,8 @@ class MediaCRUD(IMediaCRUD): 라벨·alt 는 저장하되(사람이 보고 고칠 재료), 승인 상태로 올리지 않는 게 핵심이다.""" try: query = ( - update(media) - .where(media.media_id == media_id, media.deleted == False) # noqa: E712 + update(place_photos) + .where(place_photos.media_id == media_id, place_photos.deleted == False) # noqa: E712 .values(label=label, alt_text=alt_text, vision_confidence=confidence, status=status, updated_at=ts) ) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) @@ -85,8 +85,8 @@ class MediaCRUD(IMediaCRUD): """사람이 사진을 승인/반려한다.""" try: query = ( - update(media) - .where(media.media_id == media_id, media.place_id == place_id, media.deleted == False) # noqa: E712 + update(place_photos) + .where(place_photos.media_id == media_id, place_photos.place_id == place_id, place_photos.deleted == False) # noqa: E712 .values(status=status, updated_at=ts) ) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) diff --git a/solution/backend/crud/place_content_crud.py b/solution/backend/crud/place_content_crud.py new file mode 100644 index 0000000..ad501f0 --- /dev/null +++ b/solution/backend/crud/place_content_crud.py @@ -0,0 +1,133 @@ +from sqlalchemy import and_, select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import area_contents, place_area_refs +from common.utils.gtime import GTime + + +class PlaceContentCRUD: + """업장 주변의 지역 콘텐츠. + + ★ 실체와 관계가 갈려 있다 (2026-09-09). + 예전에는 한 테이블이 값을 통째로 들고 있었고 키가 place_id 라, 업장마다 TourAPI + 응답이 복제됐다 — 실측 조이모텔 한 곳에 144행이고 같은 축제가 업장 수만큼 늘었다. + 지금은 실체가 `area_contents` 에 한 행(전국 공용, external_id 로 유일)이고 + `place_area_refs` 에는 그 업장에서만 다른 것 — 거리와 숨김 — 만 남는다. + 그래서 읽을 때 조인이 하나 는다. 그 값으로 복제를 없앴다. + """ + + async def list_by_place(self, db, place_id, *, include_hidden: bool = True): + """이 업장 주변의 콘텐츠. 실체(area_contents)와 거리(place_area_refs)를 함께 준다.""" + conds = [ + place_area_refs.place_id == place_id, + place_area_refs.deleted == False, # noqa: E712 + area_contents.deleted == False, # noqa: E712 + ] + if not include_hidden: + conds.append(place_area_refs.hidden == False) # noqa: E712 + return await DB_SESSION_MNG.execute( + db, + select( + area_contents.local_content_id, + area_contents.content_type, + area_contents.external_id, + area_contents.title, + area_contents.body, + area_contents.latitude, + area_contents.longitude, + area_contents.display_end_at, + # ★ 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다. + place_area_refs.distance_m.label("distance_m"), + place_area_refs.hidden.label("hidden"), + ) + .join(area_contents, area_contents.local_content_id == place_area_refs.local_content_id) + .where(and_(*conds)) + .order_by( + area_contents.content_type.asc(), + place_area_refs.distance_m.asc(), + ), + ) + + async def upsert_content(self, db, values: dict): + """공용 콘텐츠 한 건. (source, external_id) 가 같으면 갱신한다 — 지역과 무관하게 한 벌이다. + + ★ RETURNING 을 쓰지 않는다. 세션 매니저의 execute 는 SELECT 만 받고 + ("DO NOT USE NON-SELECT QUERY IN DBJOB"), 쓰기는 add 로 간다. id 는 뒤이어 조회한다. + """ + stmt = pg_insert(area_contents).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=[area_contents.source, area_contents.external_id], + index_where=and_( + area_contents.deleted == False, # noqa: E712 + area_contents.external_id.isnot(None), + ), + set_={ + "title": stmt.excluded.title, + "body": stmt.excluded.body, + "latitude": stmt.excluded.latitude, + "longitude": stmt.excluded.longitude, + "region_code": stmt.excluded.region_code, + "display_end_at": stmt.excluded.display_end_at, + "collected_at": stmt.excluded.collected_at, + "updated_at": GTime.UTC(), + }, + ) + return await DB_SESSION_MNG.add(db, stmt) + + async def find_content_id(self, db, source: int, external_id: str): + """방금 upsert 한 공용 콘텐츠의 id.""" + return await DB_SESSION_MNG.execute( + db, + select(area_contents.local_content_id).where( + area_contents.source == source, + area_contents.external_id == external_id, + area_contents.deleted == False, # noqa: E712 + ).limit(1), + ) + + async def upsert_ref(self, db, place_id, local_content_id, distance_m): + """업장 ↔ 콘텐츠 관계. ★ hidden 은 건드리지 않는다 — 운영자가 숨긴 것을 재수집이 되살리면 안 된다.""" + stmt = pg_insert(place_area_refs).values( + place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[place_area_refs.place_id, place_area_refs.local_content_id], + set_={"distance_m": stmt.excluded.distance_m, "deleted": False, "updated_at": GTime.UTC()}, + ) + return await DB_SESSION_MNG.add(db, stmt) + + async def soft_delete_missing(self, db, place_id, keep_ids: set): + """이번 응답에 없는 **관계**를 끊는다. 실체(area_contents)는 지우지 않는다 — + 다른 업장이 같은 장소를 가리키고 있을 수 있다.""" + err, rows = await DB_SESSION_MNG.execute( + db, + select(place_area_refs.local_content_id).where( + place_area_refs.place_id == place_id, + place_area_refs.deleted == False, # noqa: E712 + ), + ) + # 단일 컬럼 SELECT 라 행이 스칼라로 온다. + # ★ 단일 컬럼 SELECT 는 세션 매니저가 scalars() 로 편다 — 행이 곧 값이다. + gone = [r for r in (rows or []) if r not in keep_ids] + if not gone: + return err, 0 + return await DB_SESSION_MNG.add_with_rowcount( + db, + update(place_area_refs) + .where(place_area_refs.place_id == place_id, place_area_refs.local_content_id.in_(gone)) + .values(deleted=True, updated_at=GTime.UTC()), + ) + + async def set_hidden(self, db, place_id, local_content_id, hidden: bool): + """이 업장에서만 숨긴다. 실체는 그대로라 다른 업장에는 계속 보인다.""" + return await DB_SESSION_MNG.add_with_rowcount( + db, + update(place_area_refs) + .where( + place_area_refs.place_id == place_id, + place_area_refs.local_content_id == local_content_id, + place_area_refs.deleted == False, # noqa: E712 + ) + .values(hidden=hidden, updated_at=GTime.UTC()), + ) diff --git a/solution/backend/crud/place_crud.py b/solution/backend/crud/place_crud.py index 70316aa..3ab125a 100644 --- a/solution/backend/crud/place_crud.py +++ b/solution/backend/crud/place_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import place_links, places, units +from common.database.model.models import place_channels, places, place_units from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -38,11 +38,11 @@ class IPlaceCRUD(ABC): pass @abstractmethod - async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, units]: + async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, place_units]: pass @abstractmethod - async def add_unit(self, cdb: AsyncSession, unit: units) -> ErrorType: + async def add_unit(self, cdb: AsyncSession, unit: place_units) -> ErrorType: pass @abstractmethod @@ -50,7 +50,7 @@ class IPlaceCRUD(ABC): pass @abstractmethod - async def add_link(self, cdb: AsyncSession, link: place_links) -> ErrorType: + async def add_link(self, cdb: AsyncSession, link: place_channels) -> ErrorType: pass @abstractmethod @@ -156,9 +156,9 @@ class PlaceCRUD(IPlaceCRUD): async def list_units(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, list]: try: query = ( - select(units) - .where(units.place_id == place_id, units.deleted == False) # noqa: E712 - .order_by(units.sort_order.asc(), units.created_at.asc()) + select(place_units) + .where(place_units.place_id == place_id, place_units.deleted == False) # noqa: E712 + .order_by(place_units.sort_order.asc(), place_units.created_at.asc()) ) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) return (err_type, list(rows) if err_type == ErrorType.SUCCESS else []) @@ -166,11 +166,11 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] - async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, units]: + async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, place_units]: try: query = ( - select(units) - .where(units.unit_id == unit_id, units.place_id == place_id, units.deleted == False) # noqa: E712 + select(place_units) + .where(place_units.unit_id == unit_id, place_units.place_id == place_id, place_units.deleted == False) # noqa: E712 .limit(1) ) err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) @@ -183,7 +183,7 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None - async def add_unit(self, cdb: AsyncSession, unit: units) -> ErrorType: + async def add_unit(self, cdb: AsyncSession, unit: place_units) -> ErrorType: try: return await DB_SESSION_MNG.insert(cdb, unit) except Exception as ex: @@ -193,17 +193,17 @@ class PlaceCRUD(IPlaceCRUD): async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]: """채널 URL 목록. confirmed_only=True 면 ★ 크롤링 대상(확정된 URL)만.""" try: - conditions = [place_links.place_id == place_id, place_links.deleted == False] # noqa: E712 + conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712 if confirmed_only: - conditions.append(place_links.confirmed_at.is_not(None)) - query = select(place_links).where(and_(*conditions)).order_by(place_links.discovered_at.asc()) + conditions.append(place_channels.confirmed_at.is_not(None)) + query = select(place_channels).where(and_(*conditions)).order_by(place_channels.discovered_at.asc()) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) return (err_type, list(rows) if err_type == ErrorType.SUCCESS else []) except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] - async def add_link(self, cdb: AsyncSession, link: place_links) -> ErrorType: + async def add_link(self, cdb: AsyncSession, link: place_channels) -> ErrorType: try: return await DB_SESSION_MNG.insert(cdb, link) except Exception as ex: @@ -218,12 +218,12 @@ class PlaceCRUD(IPlaceCRUD): 경로를 늘리지 않으려고 일부러 좁게 열어 둔다(collect_service.discover_naver_place).""" try: query = ( - update(place_links) + update(place_channels) .where( - place_links.place_id == place_id, - place_links.url == url, - place_links.confirmed_at.is_(None), - place_links.deleted == False, # noqa: E712 + place_channels.place_id == place_id, + place_channels.url == url, + place_channels.confirmed_at.is_(None), + place_channels.deleted == False, # noqa: E712 ) .values(confirmed_at=ts, confirmed_by=user_id, updated_at=ts) ) @@ -246,11 +246,11 @@ class PlaceCRUD(IPlaceCRUD): 생성 시점에만 근거로 넘긴다(services/copy_service.py).""" try: query = ( - update(place_links) + update(place_channels) .where( - place_links.place_id == place_id, - place_links.url == url, - place_links.deleted == False, # noqa: E712 + place_channels.place_id == place_id, + place_channels.url == url, + place_channels.deleted == False, # noqa: E712 ) .values(raw=raw, updated_at=GTime.UTC()) ) @@ -263,12 +263,12 @@ class PlaceCRUD(IPlaceCRUD): """미확정 링크만 확정한다(이미 확정된 건 rowcount 0 — 동시 처리 가드).""" try: query = ( - update(place_links) + update(place_channels) .where( - place_links.link_id == link_id, - place_links.place_id == place_id, - place_links.confirmed_at.is_(None), - place_links.deleted == False, # noqa: E712 + place_channels.link_id == link_id, + place_channels.place_id == place_id, + place_channels.confirmed_at.is_(None), + place_channels.deleted == False, # noqa: E712 ) .values(confirmed_at=ts, confirmed_by=user_id, updated_at=ts) ) diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index be7b47a..922af6b 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import places, publish_logs, site_versions, sites +from common.database.model.models import places, site_publish_logs, site_versions, sites from common.enums import BuildStatus, ErrorType, SiteStatus from common.logger import LOG from common.utils.gtime import GTime @@ -54,7 +54,7 @@ class ISiteCRUD(ABC): pass @abstractmethod - async def add_log(self, cdb: AsyncSession, log: publish_logs) -> ErrorType: + async def add_log(self, cdb: AsyncSession, log: site_publish_logs) -> ErrorType: pass @abstractmethod @@ -214,7 +214,7 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0 - async def add_log(self, cdb: AsyncSession, log: publish_logs) -> ErrorType: + async def add_log(self, cdb: AsyncSession, log: site_publish_logs) -> ErrorType: try: return await DB_SESSION_MNG.insert(cdb, log) except Exception as ex: @@ -224,9 +224,9 @@ class SiteCRUD(ISiteCRUD): async def list_logs(self, cdb: AsyncSession, site_id, limit: int = 50) -> Tuple[ErrorType, list]: try: query = ( - select(publish_logs) - .where(publish_logs.site_id == site_id, publish_logs.deleted == False) # noqa: E712 - .order_by(publish_logs.created_at.desc()) + select(site_publish_logs) + .where(site_publish_logs.site_id == site_id, site_publish_logs.deleted == False) # noqa: E712 + .order_by(site_publish_logs.created_at.desc()) .limit(limit) ) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) diff --git a/solution/backend/router/v1/local/local.py b/solution/backend/router/v1/local/local.py index 7b4b870..7099753 100644 --- a/solution/backend/router/v1/local/local.py +++ b/solution/backend/router/v1/local/local.py @@ -1,10 +1,15 @@ +import uuid + from fastapi import APIRouter, Depends, Query from common.enums import LocalContentStatus from common.models.gmodel import UserInfo from router.v1.validator.dependencies import RequireOwner, RemoveNoneResponse from services.local_content_service import LocalContentService -from .protocol import ReqPublishLocalContent, ReqSyncFestivals, ReqUpdateLocalContent, ResLocalContentList, ResSyncFestivals, ResWeather +from .protocol import ( + ReqHidePlaceContent, ReqPublishLocalContent, ReqUpdateLocalContent, + ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, +) router = APIRouter(prefix="/v1/admin/local-content", tags=["LocalContent"]) weather_router = APIRouter(prefix="/v1/local", tags=["LocalContent"]) @@ -20,6 +25,35 @@ async def get_weather( return RemoveNoneResponse(await service.get_weather(region_code, latitude, longitude)) +@weather_router.get("/guide", response_model=ResLocalGuide, summary="업장 주변 가이드(맛집·명소·축제·코스) — 에디터 캔버스용") +async def get_guide( + place_id: uuid.UUID = Query(), + service: LocalContentService = Depends(), +): + """날씨와 같은 공개 조회다 — 운영자가 숨기지 않은 공공데이터만 나가므로 인증을 요구하지 않는다.""" + return RemoveNoneResponse(await service.get_guide(place_id)) + + +@router.get("/place/{place_id}", response_model=ResPlaceContentList, summary="업장 주변정보 목록 (숨김 포함)") +async def list_place_contents(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)): + return RemoveNoneResponse(await service.list_place_contents(place_id)) + + +@router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)") +async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)): + """빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다.""" + return RemoveNoneResponse(await service.sync_place_by_id(place_id)) + + +@router.post("/place-content/{place_content_id}/hide", response_model=ResPlaceContentList, summary="주변정보 숨김/해제") +async def hide_place_content( + place_content_id: uuid.UUID, req: ReqHidePlaceContent, + service: LocalContentService = Depends(), _user=Depends(RequireOwner), +): + """숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다.""" + return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden)) + + @router.get("", response_model=ResLocalContentList) async def list_contents( status: LocalContentStatus | None = Query(None), region_code: str | None = Query(None), @@ -28,11 +62,6 @@ async def list_contents( return RemoveNoneResponse(await service.list(status.value if status else None, region_code)) -@router.post("/sync-festivals", response_model=ResSyncFestivals) -async def sync_festivals(req: ReqSyncFestivals, service: LocalContentService = Depends(), _user=Depends(RequireOwner)): - return RemoveNoneResponse(await service.sync_festivals(req)) - - @router.post("/publish", response_model=ResLocalContentList) async def publish(req: ReqPublishLocalContent, service: LocalContentService = Depends(), user=Depends(RequireOwner)): return RemoveNoneResponse(await service.publish(req.content_ids, user.user_id)) diff --git a/solution/backend/router/v1/local/protocol.py b/solution/backend/router/v1/local/protocol.py index 9e54327..23d6519 100644 --- a/solution/backend/router/v1/local/protocol.py +++ b/solution/backend/router/v1/local/protocol.py @@ -25,10 +25,29 @@ class LocalContentData(WebPacketProtocol): display_end_at: datetime | None = None -class ReqSyncFestivals(WebPacketProtocol): - region_code: str = Field(min_length=1, max_length=10) - area_code: str | None = None - start_date: str | None = Field(default=None, pattern=r"^\d{8}$") +class PlaceContentData(WebPacketProtocol): + """업장 반경 주변정보 1건(admin 목록용).""" + model_config = ConfigDict(from_attributes=True) + + place_content_id: uuid.UUID + place_id: uuid.UUID + content_type: LocalContentType + external_id: str + title: str + body: dict[str, Any] + distance_m: int + has_image: bool + hidden: bool + display_end_at: datetime | None = None + collected_at: datetime | None = None + + +class ResPlaceContentList(Res_WebPacketProtocol): + contents: list[PlaceContentData] = [] + + +class ReqHidePlaceContent(WebPacketProtocol): + hidden: bool class ReqPublishLocalContent(WebPacketProtocol): @@ -46,9 +65,25 @@ class ResLocalContentList(Res_WebPacketProtocol): contents: list[LocalContentData] = [] -class ResSyncFestivals(Res_WebPacketProtocol): - collected: int = 0 - skipped: int = 0 +class ResSyncPlace(Res_WebPacketProtocol): + """업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지.""" + + festivals: int = 0 + attractions: int = 0 + restaurants: int = 0 + courses: int = 0 + changed: bool = False + + +class ResLocalGuide(Res_WebPacketProtocol): + """에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다 + (services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다.""" + + attractions: list[dict[str, Any]] = [] + restaurants: list[dict[str, Any]] = [] + festivals: list[dict[str, Any]] = [] + courses: list[dict[str, Any]] = [] + synced_at: str | None = None class WeatherData(WebPacketProtocol): diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index 7848e36..94e4898 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -40,6 +40,8 @@ class Req_VerifyPlace(PlaceProtocol): latitude: Optional[Decimal] = None longitude: Optional[Decimal] = None region_code: Optional[str] = None + # 외부 장소 DB 의 분류 문자열(후보의 category_name). 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준으로 박제한다. + category_name: Optional[str] = None class Req_VerifyPlaceByUrl(PlaceProtocol): diff --git a/solution/backend/scripts/migrate.py b/solution/backend/scripts/migrate.py new file mode 100644 index 0000000..4597674 --- /dev/null +++ b/solution/backend/scripts/migrate.py @@ -0,0 +1,111 @@ +"""스키마 마이그레이션 — 이미 만들어진 DB 를 init.sql 최신으로 끌어올린다. + + cd solution/backend && .venv/bin/python scripts/migrate.py + cd solution/backend && .venv/bin/python scripts/migrate.py --dry-run + +★ 왜 필요한가 (2026-09-09) + `init-data/init.sql` 은 **DB 를 처음 만들 때만** 돈다(postgres 이미지의 초기화 훅). + 그래서 파일에 컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다. + 실제로 로컬 DB 에 `local.place_contents` 테이블과 `place.places.external_category` + 컬럼이 없었고, TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었다. + 화면에는 "그냥 안 나오는 것"으로만 보여서 원인을 짚는 데 한참 걸렸다. + DECISIONS.md 가 예고한 그대로다 — "운영 DB 가 생기는 순간 다시 필요해진다". + +★ Alembic 을 쓰지 않는다. 이 레포는 ORM 과 init.sql 두 곳에 스키마를 두고 + `tests/test_schema_ddl.py` 로 대조하는 구조다. 거기에 세 번째 정의(Alembic 리비전)를 + 더하면 어긋날 자리가 하나 더 생긴다. 필요한 건 "안 돌린 SQL 을 순서대로 돌린다" 뿐이다. + +★ 적용 기록은 `public.schema_migrations` 에 남는다. 이미 있는 번호는 건너뛴다. + 파일은 재실행 안전하게 쓰므로(IF NOT EXISTS), 기록이 날아가도 다시 돌리면 그만이다. +""" +import argparse +import asyncio +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("APP_ENV", "local") + +from sqlalchemy import text # noqa: E402 + +from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402 +from common.enums import DBWRType # noqa: E402 +from common.database.model.models import places # noqa: E402 + +# parents[3] = 레포 루트 (scripts → backend → solution → 루트). +# ★ test_schema_ddl.py 와 같은 계산이다 — 폴더를 옮기면 둘 다 고친다. +MIGRATIONS_DIR = Path(__file__).resolve().parents[3] / "postgres-init" / "migrations" + +_LEDGER_DDL = """ +CREATE TABLE IF NOT EXISTS public.schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +) +""" + + +def pending(applied: set[str]) -> list[Path]: + """아직 안 돌린 파일. 파일명 순서가 곧 적용 순서다.""" + files = sorted(p for p in MIGRATIONS_DIR.glob("*.sql")) + return [p for p in files if p.stem not in applied] + + +async def main(dry_run: bool) -> int: + if not MIGRATIONS_DIR.is_dir(): + print(f"마이그레이션 폴더가 없습니다: {MIGRATIONS_DIR}") + return 1 + + db = await DB_SESSION_MNG.start_session(places.DBType(), DBWRType.DB_WRITE.value) + try: + await db.execute(text(_LEDGER_DDL)) + await db.commit() + rows = await db.execute(text("SELECT version FROM public.schema_migrations")) + applied = {r[0] for r in rows} + + todo = pending(applied) + if not todo: + print(f"적용할 마이그레이션이 없습니다 (적용됨 {len(applied)}건)") + return 0 + + print(f"적용 대상 {len(todo)}건:") + for path in todo: + print(f" {path.stem}") + if dry_run: + print("\n--dry-run — 아무것도 적용하지 않았습니다.") + return 0 + + for path in todo: + sql = path.read_text(encoding="utf-8") + print(f"\n▶ {path.stem}") + try: + # ★ 파일 하나를 한 트랜잭션으로 돌린다 — 중간에 실패하면 그 파일은 통째로 되돌아간다. + # 반쯤 적용된 파일이 기록에 남으면 다음 실행이 그것을 건너뛴다. + # ★ asyncpg 드라이버 커넥션으로 직접 보낸다. SQLAlchemy 의 text() 는 prepared + # statement 가 되는데, asyncpg 는 거기에 문장을 여러 개 못 넣는다 + # ("cannot insert multiple commands into a prepared statement"). + # 마이그레이션 파일은 본래 여러 문장이라 이 경로가 맞다. + raw = await (await db.connection()).get_raw_connection() + await raw.driver_connection.execute(sql) + await db.execute( + text("INSERT INTO public.schema_migrations (version) VALUES (:v)"), + {"v": path.stem}, + ) + await db.commit() + print(" 적용됨") + except Exception as ex: + await db.rollback() + print(f" 실패 — {ex}") + print(" ★ 여기서 멈춥니다. 뒤 파일은 돌리지 않습니다(순서가 뜻을 갖는다).") + return 1 + print(f"\n완료 — {len(todo)}건 적용") + return 0 + finally: + await DB_SESSION_MNG.end_session(places.DBType(), DBWRType.DB_WRITE.value) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true", help="적용하지 않고 대상만 출력") + args = parser.parse_args() + raise SystemExit(asyncio.run(main(args.dry_run))) diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index e26a95c..3ec9952 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -14,7 +14,7 @@ import uuid from sqlalchemy import select from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import place_links, places, publish_logs, site_versions, sites +from common.database.model.models import place_channels, places, site_publish_logs, site_versions, sites from common.enums import ( BuildStatus, DBWRType, @@ -30,6 +30,7 @@ from common.utils.gtime import GTime from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail +from services.local_content_service import LocalContentService from services.site_payload import emit_payload from services.snapshot import build_snapshot @@ -69,13 +70,13 @@ async def _load_links(place_id: str) -> list: 스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다. ★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다.""" err, rows = await DB_SESSION_MNG.execute_lambda( - place_links.DBType(), + place_channels.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute( s, - select(place_links).where( - place_links.place_id == uuid.UUID(place_id), - place_links.deleted == False, # noqa: E712 + select(place_channels).where( + place_channels.place_id == uuid.UUID(place_id), + place_channels.deleted == False, # noqa: E712 ), ), ) @@ -84,7 +85,7 @@ async def _load_links(place_id: str) -> list: async def _log(site_id, version_id, action: PublishAction, result: PublishResult, gate=None, actor=None): """발행 시도를 기록한다. 거부됐으면 사유와 상세를 그대로 남긴다 — 운영자가 뭘 고칠지 알아야 한다.""" - row = publish_logs( + row = site_publish_logs( site_id=site_id, site_version_id=version_id, action=action.value, @@ -93,7 +94,7 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult detail=(gate.as_log() if gate is not None and not gate.passed else None), actor_user_id=uuid.UUID(actor) if actor else None, ) - await DB_SESSION_MNG.execute_lambda_run([publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)]) + await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)]) async def run_build(job: dict) -> dict: @@ -114,6 +115,17 @@ async def run_build(job: dict) -> dict: raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") site = await ensure_site(place_id) + + # ★ 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 + # 이때 받은 값이 실린다. 실패해도 빌드는 계속한다: 곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고, + # place_contents 는 직전 값을 그대로 갖고 있다. + try: + synced = await LocalContentService().sync_place(place) + if not synced.result.success: + LOG.w(f"[build] place={place_id} 주변정보 갱신 건너뜀(직전 값 사용): {synced.msg}") + except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다 + LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}") + snapshot = await build_snapshot(place) v_err, version_no = await DB_SESSION_MNG.execute_lambda( @@ -212,7 +224,7 @@ async def run_build(job: dict) -> dict: # ---- 2차 게이트: 렌더 산출물 기준 ---- # ★ 렌더가 실패했더라도 게이트를 **먼저** 돌린다. 렌더러가 페이지 쓰기를 거부한 이유가 # 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다. - # 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 — + # 여기서 사유를 정확히 골라야 site_publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 — # 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다. gate = publish_gate.evaluate( PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 6f24ca0..c765d84 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -5,8 +5,8 @@ import uuid from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import facts as facts_model -from common.database.model.models import media, place_links, places, units +from common.database.model.models import place_facts as facts_model +from common.database.model.models import place_photos, place_channels, places, place_units from common.enums import DBWRType, ErrorType, LinkChannel, MediaStatus, PlaceCategory, PlaceStatus, SourceType from common.logger import LOG from common.utils.gtime import GTime @@ -356,7 +356,7 @@ async def store_facts(user_info, place_id: str, sources: list, unit_map: dict) - stat = {"stored": 0, "refreshed": 0, "candidate": 0, "rejected": 0, "by_reason": {}} for source in sources: - for cf in source.facts: + for cf in source.place_facts: unit_id = unit_map.get(cf.unit_name) if cf.scope == "unit" else None if cf.scope == "unit" and unit_id is None: stat["rejected"] += 1 diff --git a/solution/backend/services/collector/tour_api_adapter.py b/solution/backend/services/collector/tour_api_adapter.py index 07f5264..650c8ba 100644 --- a/solution/backend/services/collector/tour_api_adapter.py +++ b/solution/backend/services/collector/tour_api_adapter.py @@ -284,9 +284,11 @@ class TourApiAdapter: def _lodging_facts(self, intro: dict) -> list[CollectedFact]: """숙박 detailIntro2 → 사업장 단위 fact. - ★ 스키마에 없는 값은 만들지 않는다. roomcount·scalelodging·foodplace 는 - lodging 스키마에 자리가 없어서 **일부러 버린다** — 억지로 다른 key 에 넣으면 - '식음료장 있음' 이 '조식 제공' 으로 둔갑한다. + ★ 스키마에 없는 값은 만들지 않는다. foodplace('식음료장 있음')는 lodging 스키마에 + 자리가 없어서 **일부러 버린다** — 억지로 breakfast 에 넣으면 '조식 제공' 으로 둔갑한다. + ★ roomcount·scalelodging·accomcountlodging·subfacility 는 2026-09-07 에 자리를 만들었다 + (total_rooms·building_scale·accommodation_capacity·facilities). 실측(오블로모프 3103191)에서 + TourAPI 가 준 값의 절반이 자리가 없어 버려지고 있었다. """ return self._collect([ ("check_in_time", self._plain(intro.get("checkintime"))[:40]), @@ -296,6 +298,11 @@ class TourApiAdapter: ("parking", self._bool_str(self._head_bool(intro.get("parkinglodging")))), ("pickup_service", self._bool_str(self._head_bool(intro.get("pickup")))), ("bbq_available", self._bool_str(self._yn(intro.get("barbecue")))), + ("facilities", self._plain(intro.get("subfacility"))[:500]), + ("total_rooms", self._number(intro.get("roomcount"))), + ("accommodation_capacity", self._number(intro.get("accomcountlodging"))), + # "약 23평" · "대지 면적 11,570㎡" 처럼 단위가 제각각이라 숫자로 뽑지 않고 원문을 싣는다. + ("building_scale", self._plain(intro.get("scalelodging"))[:200]), ]) def _restaurant_facts(self, intro: dict) -> list[CollectedFact]: @@ -362,6 +369,16 @@ class TourApiAdapter: ("peak_price", self._number(row.get("roompeakseasonminfee1")), name), ("has_kitchen", self._bool_str(self._yn(row.get("roomcook"))), name), ("has_aircon", self._bool_str(self._yn(row.get("roomaircondition"))), name), + # 객실 편의시설 Y/N. ★ 빈 값은 '없음'이 아니라 '모름'이다 — _yn 이 None 을 주면 fact 를 만들지 않는다. + ("has_bathroom", self._bool_str(self._yn(row.get("roombathfacility"))), name), + ("has_tv", self._bool_str(self._yn(row.get("roomtv"))), name), + ("has_internet", self._bool_str(self._yn(row.get("roominternet"))), name), + ("has_refrigerator", self._bool_str(self._yn(row.get("roomrefrigerator"))), name), + ("has_hairdryer", self._bool_str(self._yn(row.get("roomhairdryer"))), name), + ("has_toiletries", self._bool_str(self._yn(row.get("roomtoiletries"))), name), + ("has_table", self._bool_str(self._yn(row.get("roomtable"))), name), + ("has_sofa", self._bool_str(self._yn(row.get("roomsofa"))), name), + ("has_home_theater", self._bool_str(self._yn(row.get("roomhometheater"))), name), ]) return facts diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index 2869edb..069ed16 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -10,7 +10,7 @@ import uuid from common.category_schema import CategorySchemaError, get_schema from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import facts, faqs, place_links, places, units +from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units from common.enums import ( PUBLISHABLE_FACT_STATUSES, DBWRType, @@ -64,7 +64,7 @@ async def run_copy(job: dict) -> dict: # ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다. pid = uuid.UUID(place_id) f_err, fact_rows = await DB_SESSION_MNG.execute_lambda( - facts.DBType(), + place_facts.DBType(), DBWRType.DB_READ.value, lambda s: _fact_crud.list_facts(s, pid, None, None, True, True), ) @@ -81,7 +81,7 @@ async def run_copy(job: dict) -> dict: for r in fact_rows if r.unit_id is None and (r.value or "").strip() ] - # ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_links.raw 에 박제된 글이다. + # ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_channels.raw 에 박제된 글이다. # # 왜 필요한가: 소개 원문(TourAPI overview·네이버 description)에만 있는 정보가 있다. # '전면 통창 실내 온수풀', '판교역에서 3분' 같은 것들인데, 이게 근거에 없으면 @@ -91,7 +91,7 @@ async def run_copy(job: dict) -> dict: # 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31). # 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다. l_err, link_rows = await DB_SESSION_MNG.execute_lambda( - place_links.DBType(), + place_channels.DBType(), DBWRType.DB_READ.value, lambda s: _place_crud.list_links(s, pid, True), ) @@ -110,7 +110,7 @@ async def run_copy(job: dict) -> dict: # 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도 # "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다. u_err, unit_rows = await DB_SESSION_MNG.execute_lambda( - units.DBType(), + place_units.DBType(), DBWRType.DB_READ.value, lambda s: _place_crud.list_units(s, pid), ) @@ -196,15 +196,15 @@ async def run_copy(job: dict) -> dict: # 확인 안 된 기존 생성 FAQ 는 내리고 새로 넣는다. 사람이 확인한 FAQ 는 건드리지 않는다. await DB_SESSION_MNG.execute_lambda_claim( - faqs.DBType(), + place_faqs.DBType(), lambda s: _faq_crud.expire_generated(s, pid, now), ) - for order, faq in enumerate(copy.faqs or []): + for order, faq in enumerate(copy.place_faqs or []): if not faq.fact_keys: # ★ 근거 없는 FAQ 는 저장하지 않는다. stat["rejected"].append([faq.question, "근거 fact 없음"]) continue - row = faqs( + row = place_faqs( place_id=pid, question=faq.question, answer=faq.answer, @@ -214,7 +214,7 @@ async def run_copy(job: dict) -> dict: sort_order=order, ) run_err = await DB_SESSION_MNG.execute_lambda_run( - [faqs.DBType()], + [place_faqs.DBType()], [lambda s, r=row: _faq_crud.add_faq(s, r)], ) if run_err == ErrorType.SUCCESS: diff --git a/solution/backend/services/external/kakao.py b/solution/backend/services/external/kakao.py index 160d98e..5fcde48 100644 --- a/solution/backend/services/external/kakao.py +++ b/solution/backend/services/external/kakao.py @@ -43,9 +43,10 @@ _BASE_URL = "https://dapi.kakao.com" _KEYWORD_URL = f"{_BASE_URL}/v2/local/search/keyword.json" _COORD2REGION_URL = f"{_BASE_URL}/v2/local/geo/coord2regioncode.json" _CATEGORY_URL = f"{_BASE_URL}/v2/local/search/category.json" +_ADDRESS_URL = f"{_BASE_URL}/v2/local/search/address.json" # 초과 단가(원). 로그에 함께 남겨 어떤 호출이 비싼지 바로 보이게 한다. -_UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5} +_UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5, "address": 0.5} # 프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거. _CALL_COUNTS: Counter = Counter() @@ -362,6 +363,28 @@ class KakaoLocalClient: picked = next((d for d in docs if d.get("region_type") == "H"), docs[0]) return RegionCode.from_document(picked) + # ---- 2-1) 주소 → 좌표 ---- + async def geocode_address(self, address: str) -> Optional[tuple[float, float]]: + """도로명·지번 주소 → (위도, 경도). 결과가 없으면 None — 좌표를 지어내지 않는다. + + ★ 쓰는 곳: 좌표 없이 검증된 사업장의 주변 정보 수집(local_content_service.sync_place). + 동일 업소 검증(카카오 후보·네이버 상세)은 좌표를 같이 주므로 보통은 비어 있지 않다 — + 비는 건 옛 데이터나 좌표 없는 후보를 고른 경우다. 그때 주소로 한 번 더 찾는다. + 싸다(초과 시 건당 0.5원). 결과는 places 에 박제하므로 사업장당 1회다. + """ + query = (address or "").strip() + if not query: + return None + data = await self._get(_ADDRESS_URL, {"query": query, "size": "1"}, "address") + docs = data.get("documents") or [] + if not docs: + LOG.i(f"[kakao] 주소 → 좌표 결과 없음: {query[:60]}") + return None + lat, lon = _to_float(docs[0].get("y")), _to_float(docs[0].get("x")) # ★ y=위도 · x=경도 + if lat is None or lon is None: + return None + return lat, lon + # ---- 3) 주변 맛집·시설 ---- async def search_category( self, diff --git a/solution/backend/services/external/tour_api.py b/solution/backend/services/external/tour_api.py index a4102ae..e3cc4e2 100644 --- a/solution/backend/services/external/tour_api.py +++ b/solution/backend/services/external/tour_api.py @@ -1,17 +1,58 @@ -"""공공데이터포털 전국문화축제표준데이터 클라이언트. +"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트. -설정 키 이름은 기존 배포 계약을 유지하기 위해 TOUR_API_KEY를 그대로 사용한다. +collector/tour_api_adapter.py 가 '사업장 1곳'의 fact 를 캐는 쪽이라면, 여기는 +업장 좌표 반경 안의 곁들이 정보(맛집·관광지·축제·여행코스)를 긁는 쪽이다. +결과는 place_contents(업장 단위 캐시)에 들어가 발행본·캔버스의 지역 정보 섹션이 된다. + +★ 왜 행정구역이 아니라 좌표인가 (2026-09-04, specs/2026-09-04-tourapi-radius-spike.md) + 처음엔 법정동 코드로 areaBasedList2 를 불렀다. 그건 '그 시군구에 있는 것'이지 + '이 업장에서 가까운 것'이 아니다 — 양양군 업장 옆 5km 속초 관광지가 빠지고, + 같은 시군구 반대편 30km 맛집이 붙는다. locationBasedList2 는 좌표+반경으로 묻고 + 거리(dist)까지 준다. 실측(군산 절골길 18): 10km 안 133건, 5km 안 100건. + +★ 호출 수 — 업장당 종류별 1회 (맛집·관광지·축제) + 2026-09-08 부터 종류마다 **따로** 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 반경 없음) — + 한 걸음에 갈 맛집과 차 타고 갈 축제를 같은 반경으로 재는 게 맞지 않았다. + 여행코스(25)는 실측 결과 반경을 넓혀도 데이터가 거의 없어(전북 전체 3건) 뺐다. + +★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다 (2026-09-08 교체) + locationBasedList2(contentTypeId=15) 의 위치 색인은 못 믿는다 — 실측(군산 절골길 18): + 반경 20km 를 아무리 넓혀도 2023년에 끝난 서천 전시 1건만 나오고, 코앞 500m 의 + 진행 예정 축제(군산시간여행축제 등)는 끝내 안 잡혔다. searchFestival2 는 법정동(시도) + 단위로 묻지만 정확하고 기간까지 함께 준다 — 그래서 시도 전체를 받아 우리가 거리로 거른다. + eventStartDate 는 파라미터로 준 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직 + 진행 중인 행사는 잡히지 않는다 — 실측). 그래서 항상 **그 해 1월 1일**로 고정해 부르고, + 이미 끝난 행사(eventenddate < 오늘)만 우리가 한 번 더 거른다. + +★ 이미지 저작권 — 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 와 같은 규칙) + firstimage 는 공공누리 Type1(출처표시)·Type3(출처표시+변경금지)만 남긴다. + 발행본은 상업적 이용이라 Type2·Type4 는 싣지 못한다. 유형을 모르면 버린다. """ - -import hashlib from datetime import date -from urllib.parse import unquote +from typing import Optional +from urllib.parse import unquote, urlencode import httpx +from common.enums import LocalContentType +from common.utils.geo import haversine_m from config.server_configs import external_api_config -_URL = "https://api.data.go.kr/openapi/tn_pubr_public_cltur_fstvl_api" +BASE_URL = "https://apis.data.go.kr/B551011/KorService2" +REQUEST_TIMEOUT = 25 +PAGE_SIZE = 100 +# 반경 10km 도심은 300건을 넘지 않는다(실측 133건). 그 이상은 어차피 종류별 20건 상한에 안 든다. +MAX_PAGES = 4 + +# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용). 축제(15)는 여기 없다 — +# searchFestival2 로 따로 받는다(위 모듈 docstring 참고). 여행코스(25)도 뺐다(데이터 부족). +CONTENT_TYPE_MAP = { + "39": LocalContentType.RESTAURANT.value, + "12": LocalContentType.ATTRACTION.value, +} + +# 상업적 이용이 허용된 공공누리 유형(tour_api_adapter 와 동일 규칙·동일 이유). +_COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"}) class TourApiNotConfigured(RuntimeError): @@ -22,52 +63,225 @@ class TourApiRequestFailed(RuntimeError): pass -def _normalize(item: dict) -> dict: - """표준데이터 필드명을 화면/저장소의 공통 축제 필드로 변환한다.""" - identity = "|".join(str(item.get(key) or "") for key in ("fstvlNm", "fstvlStartDate", "fstvlEndDate", "insttCode")) - return { - "contentid": hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32], - "title": item.get("fstvlNm"), - "eventstartdate": str(item.get("fstvlStartDate") or "").replace("-", ""), - "eventenddate": str(item.get("fstvlEndDate") or "").replace("-", ""), - "addr1": item.get("rdnmadr") or item.get("lnmadr") or item.get("eventPlace"), - "mapx": item.get("longitude"), - "mapy": item.get("latitude"), - "tel": item.get("phoneNumber"), - "homepage": item.get("homepageUrl"), - "organizer": item.get("suprtInsttNm"), - "overview": item.get("relateInfo") or item.get("opar"), - "raw": item, - } +# ── HTTP ─────────────────────────────────────────────────────────────── -async def search_festivals(*, start_date: date, area_code: str | None = None, rows: int = 100) -> list[dict]: +def _service_key() -> str: key = (external_api_config.tour_api_key or "").strip() if not key: raise TourApiNotConfigured("TOUR_API_KEY is not configured") - params = { - # 포털의 "Encoding" 키를 넣어도 httpx가 이중 인코딩하지 않도록 원문으로 되돌린다. - "serviceKey": unquote(key), - "type": "json", - "numOfRows": max(1, min(rows, 1000)), - "pageNo": 1, - } + return key + + +async def _call(client: httpx.AsyncClient, op: str, **params) -> tuple[list[dict], int]: + """오퍼레이션 1회 → (항목, totalCount). 결과 없음은 빈 목록 — 없는 것과 실패를 구분한다. + + ★ 포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다 + (collector/tour_api_adapter._call 과 같은 처리). + """ + query = urlencode( + {"serviceKey": unquote(_service_key()), "MobileOS": "ETC", "MobileApp": "o2o-web4ai", + "_type": "json", "numOfRows": str(PAGE_SIZE), "pageNo": "1", **params}, + safe="", + ) + res = await client.get(f"{BASE_URL}/{op}?{query}") + if res.status_code != 200: + raise TourApiRequestFailed(f"{op} HTTP {res.status_code}") try: - async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=5.0)) as client: - response = await client.get(_URL, params=params) - response.raise_for_status() - payload = response.json() - response_data = payload.get("response", payload) - header = response_data.get("header", {}) - if header.get("resultCode") not in (None, "00", "0000"): - raise TourApiRequestFailed(header.get("resultMsg") or "TourAPI returned an error") - items_node = response_data.get("body", {}).get("items", []) - items = items_node.get("item", []) if isinstance(items_node, dict) else items_node - if isinstance(items, dict): - items = [items] - normalized = [_normalize(item) for item in (items or [])] - # API가 날짜 필터를 제공하지 않으므로 종료된 축제는 애플리케이션에서 제외한다. - cutoff = start_date.strftime("%Y%m%d") - return [item for item in normalized if not item["eventenddate"] or item["eventenddate"] >= cutoff] - except (httpx.HTTPError, ValueError, TypeError) as ex: - raise TourApiRequestFailed(str(ex)) from ex + payload = res.json() + except ValueError: + # 인증 실패·쿼터 초과는 XML 로 온다. 본문 앞부분을 그대로 올려 원인을 감추지 않는다. + raise TourApiRequestFailed(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}") + + # 게이트웨이 오류(미등록 키 등)는 200 + JSON 이지만 response 가 없다. 그것도 원인을 드러낸다. + if "response" not in payload: + raise TourApiRequestFailed(f"{op} 게이트웨이 오류: {str(payload)[:160]}") + header = payload["response"].get("header", {}) + code = str(header.get("resultCode") or "") + if code not in ("0000", "00"): + raise TourApiRequestFailed(f"{op} 실패 [{code}] {header.get('resultMsg')}") + + body = payload["response"].get("body", {}) or {} + items = (body.get("items") or {}).get("item") if isinstance(body.get("items"), dict) else None + if isinstance(items, dict): + items = [items] + total = int(body.get("totalCount") or 0) + return items or [], total + + +# ── 정규화 ────────────────────────────────────────────────────────────── + +def _int(value) -> Optional[int]: + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + return None + + +def _normalize(item: dict) -> Optional[dict]: + """locationBasedList2 항목 1건 → place_contents.body. contentid·title·거리·종류 없으면 버린다.""" + content_id = str(item.get("contentid") or "").strip() + title = str(item.get("title") or "").strip() + kind = CONTENT_TYPE_MAP.get(str(item.get("contenttypeid") or "").strip()) + distance = _int(item.get("dist")) + if not content_id or not title or kind is None or distance is None: + return None + + body = {"contentid": content_id, "title": title, "content_type": kind, "distance_m": distance} + # lclsSystm1~3 = 분류체계 대/중/소. ★ 중분류(lclsSystm2)가 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준이다. + for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd", + "lclsSystm1", "lclsSystm2", "lclsSystm3"): + value = str(item.get(key) or "").strip() + if value: + body[key] = value + + # 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다. 유형을 모르면 버린다. + image = str(item.get("firstimage") or "").strip() + license_code = str(item.get("cpyrhtDivCd") or "").strip().lower() + if image and license_code in _COMMERCIAL_OK_LICENSES: + body["firstimage"] = image + body["license"] = license_code + thumb = str(item.get("firstimage2") or "").strip() + if thumb: + body["firstimage2"] = thumb + return body + + +# ── 공개 API ──────────────────────────────────────────────────────────── + +def make_client() -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=REQUEST_TIMEOUT) + + +async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float, + *, radius_m: int, content_type_id: str) -> list[dict]: + """업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다. + + ★ mapX=경도 · mapY=위도. 뒤집으면 엉뚱한 지역이 붙는다(카카오와 같은 함정). + """ + out: list[dict] = [] + seen: set[str] = set() + for page in range(1, MAX_PAGES + 1): + items, total = await _call( + client, "locationBasedList2", + mapX=str(longitude), mapY=str(latitude), radius=str(radius_m), + contentTypeId=content_type_id, arrange="E", pageNo=str(page), + ) + for item in items: + body = _normalize(item) + if body and body["contentid"] not in seen: + seen.add(body["contentid"]) + out.append(body) + if not items or page * PAGE_SIZE >= total: + break + out.sort(key=lambda b: b["distance_m"]) + return out + + +def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]: + """searchFestival2 항목 1건 → place_contents.body. locationBasedList2 와 달리 `dist` 를 + 안 주므로(호출측이 haversine 으로 잰 값을) 그대로 받는다. 기간은 여기 이미 있다.""" + content_id = str(item.get("contentid") or "").strip() + title = str(item.get("title") or "").strip() + if not content_id or not title: + return None + + body = {"contentid": content_id, "title": title, + "content_type": LocalContentType.FESTIVAL.value, "distance_m": distance_m} + for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd", + "lclsSystm1", "lclsSystm2", "lclsSystm3", "eventstartdate", "eventenddate"): + value = str(item.get(key) or "").strip() + if value: + body[key] = value + + image = str(item.get("firstimage") or "").strip() + license_code = str(item.get("cpyrhtDivCd") or "").strip().lower() + if image and license_code in _COMMERCIAL_OK_LICENSES: + body["firstimage"] = image + body["license"] = license_code + thumb = str(item.get("firstimage2") or "").strip() + if thumb: + body["firstimage2"] = thumb + return body + + +def _festival_not_ended(body: dict, today: date) -> bool: + """종료일이 지났으면 끝난 축제 — 신지 않는다. 기간을 아예 모르면 못 믿으니 역시 뺀다. + 종료일 없이 시작일만 있으면(무기한 진행) 유지한다 — 끝났다는 증거가 없다.""" + end, start = body.get("eventenddate"), body.get("eventstartdate") + ymd = today.strftime("%Y%m%d") + if end: + return len(end) == 8 and end.isdigit() and end >= ymd + return bool(start) + + +async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float, + *, sido_code: str, today: date) -> list[dict]: + """업장이 속한 시도의 축제 **전부**(정규화, 거리순, 이미 끝난 것 제외). 반경으로 자르지 않는다. + + ★ 반경을 안 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 로 자르면 시도 안의 + 큰 축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다. + (종류별 노출 상한은 스냅샷이 20건으로 자른다 — 사진 있는 것 우선 → 가까운 순.) + ★ locationBasedList2 의 위치 색인은 못 믿어서 searchFestival2 를 쓴다(위 모듈 docstring). + eventStartDate 는 그 해 1월 1일로 **고정** — "오늘" 을 넣으면 그 이전에 시작해 아직 진행 중인 + 축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받고, 끝난 것만 여기서 거른다. + ★ 좌표 없는 항목은 뺀다 — distance_m 이 NOT NULL 이고, 거리 없는 카드는 도보 필터에 못 얹는다. + """ + start_date = date(today.year, 1, 1).strftime("%Y%m%d") + out: list[dict] = [] + seen: set[str] = set() + for page in range(1, MAX_PAGES + 1): + items, total = await _call( + client, "searchFestival2", + eventStartDate=start_date, lDongRegnCd=sido_code, pageNo=str(page), + ) + for item in items: + lnglat = _mapxy(item) # (경도, 위도) — 좌표가 없으면 거리를 잴 수 없다 + if lnglat is None: + continue + lng, lat = lnglat + distance = haversine_m(latitude, longitude, lat, lng) # 자르지 않는다 — 정렬·표시용 + body = _normalize_festival(item, round(distance)) + if not body or body["contentid"] in seen: + continue + if not _festival_not_ended(body, today): + continue + seen.add(body["contentid"]) + out.append(body) + if not items or page * PAGE_SIZE >= total: + break + out.sort(key=lambda b: b["distance_m"]) + return out + + +def _mapxy(item: dict) -> Optional[tuple[float, float]]: + """(경도, 위도). 좌표가 없거나 숫자가 아니면 None — 거리를 잴 수 없는 항목은 반경으로 못 거른다.""" + try: + return float(item.get("mapx")), float(item.get("mapy")) + except (TypeError, ValueError): + return None + + +async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Optional[str]: + """콘텐츠 1건의 중분류 코드(lclsSystm2). 못 구하면 None. + + 업장 자신이 TourAPI 에 등록돼 있을 때(place_links 의 tour:// 링크) 그 업장의 업태를 여기서 읽는다 — + 주변 맛집에서 같은 중분류를 빼기 위해서다. 외부 분류 문자열 매핑보다 이 값이 우선이다(같은 체계라 오차가 없다). + """ + items, _ = await _call(client, "detailCommon2", contentId=content_id) + if not items: + return None + code = str(items[0].get("lclsSystm2") or "").strip() + return code or None + + +def festival_is_current(period: Optional[tuple[str, str]], today: date) -> bool: + """종료일이 지났으면 끝난 축제 — 싣지 않는다. 기간을 아예 모르면(None) 못 믿으니 역시 뺀다. + 종료일 없이 시작일만 있으면(무기한 진행) 시작일이 지났어도 유지한다 — 끝났다는 증거가 없다.""" + if not period: + return False + start, end = period + ymd = today.strftime("%Y%m%d") + if end: + return len(end) == 8 and end.isdigit() and end >= ymd + return bool(start) diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index c22ded9..4923339 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -4,7 +4,7 @@ from fastapi import Depends from common.category_schema import CategorySchemaError, get_schema from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import facts, places, units +from common.database.model.models import place_facts, places, place_units from common.enums import ( FACT_STATUS_TRANSITIONS, PUBLISHABLE_FACT_STATUSES, @@ -121,7 +121,7 @@ class FactService: return res list_err, rows = await DB_SESSION_MNG.execute_lambda( - facts.DBType(), + place_facts.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_facts(s, uuid.UUID(place_id), unit_id, None, publishable_only, True), ) @@ -195,7 +195,7 @@ class FactService: unit_id = unit_map.get(fact.unit_name) if fact.unit_name else None if fact.unit_name and unit_id is None: row.reason = f"'{fact.unit_name}' 단위가 아직 없다 — 수집으로 객실·메뉴가 먼저 만들어져야 한다" - res.facts.append(row) + res.place_facts.append(row) res.rejected += 1 continue @@ -212,7 +212,7 @@ class FactService: else: row.reason = write.result.desc or "저장 실패" res.rejected += 1 - res.facts.append(row) + res.place_facts.append(row) LOG.i(f"[fact] 붙여넣기 추출 — 저장 {res.stored}건 · 반려 {res.rejected}건 (place={place_id})") return res @@ -220,7 +220,7 @@ class FactService: async def _unit_map(self, place_id: str) -> dict: """단위 이름 → unit_id. 붙여넣기 값이 어느 객실 것인지 잇는 데만 쓴다.""" err, rows = await DB_SESSION_MNG.execute_lambda( - units.DBType(), + place_units.DBType(), DBWRType.DB_READ.value, lambda s: self.place_crud.list_units(s, uuid.UUID(place_id)), ) @@ -264,7 +264,7 @@ class FactService: pid = uuid.UUID(place_id) pub_err, published = await DB_SESSION_MNG.execute_lambda( - facts.DBType(), + place_facts.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_published_fact(s, pid, req.unit_id, req.key), ) @@ -278,7 +278,7 @@ class FactService: # ── 값이 그대로다 — 검증을 초기화하지 않고 '언제 다시 확인했는지'만 갱신 ── if same_value: run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim( - facts.DBType(), + place_facts.DBType(), lambda s: self.crud.refresh_collected(s, published.fact_id, req.source_type.value, req.source_url, now), ) if run_err != ErrorType.SUCCESS: @@ -299,7 +299,7 @@ class FactService: target_status = FactStatus.PENDING_OWNER if published is not None else FactStatus.UNVERIFIED cand_err, candidate = await DB_SESSION_MNG.execute_lambda( - facts.DBType(), + place_facts.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_candidate(s, pid, req.unit_id, req.key, req.source_type.value), ) @@ -310,7 +310,7 @@ class FactService: # 같은 출처가 이미 올려둔 후보가 있으면 갱신한다(같은 후보가 계속 쌓이지 않게). if candidate is not None: run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim( - facts.DBType(), + place_facts.DBType(), lambda s: self.crud.update_candidate( s, candidate.fact_id, req.value, req.source_url, target_status.value, now ), @@ -334,7 +334,7 @@ class FactService: expires_at=req.expires_at, ) run_err = await DB_SESSION_MNG.execute_lambda_run( - [facts.DBType()], + [place_facts.DBType()], [lambda s: self.crud.add_fact(s, fact)], ) if run_err != ErrorType.SUCCESS: @@ -364,7 +364,7 @@ class FactService: expires_at=req.expires_at, ) run_err = await DB_SESSION_MNG.execute_lambda_run( - [facts.DBType()], + [place_facts.DBType()], [ lambda s: self._expire_then_ok(s, pid, req, now), lambda s: self.crud.add_fact(s, fact), @@ -387,7 +387,7 @@ class FactService: async def _reload(self, res, pid, fact_id): _e, row = await DB_SESSION_MNG.execute_lambda( - facts.DBType(), + place_facts.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_fact(s, pid, fact_id), ) @@ -409,7 +409,7 @@ class FactService: pid = uuid.UUID(place_id) fid = uuid.UUID(fact_id) get_err, fact = await DB_SESSION_MNG.execute_lambda( - facts.DBType(), + place_facts.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_fact(s, pid, fid), ) @@ -445,7 +445,7 @@ class FactService: if promoting: funcs.append(lambda s: self._reject_others_ok(s, pid, fact, now, fid)) - run_err = await DB_SESSION_MNG.execute_lambda_run([facts.DBType()], funcs) + run_err = await DB_SESSION_MNG.execute_lambda_run([place_facts.DBType()], funcs) if run_err != ErrorType.SUCCESS: res.result.SetResult( ErrorType.FACT_INVALID_TRANSITION if run_err == ErrorType.DB_EMPTY_DATA else run_err diff --git a/solution/backend/services/faq_service.py b/solution/backend/services/faq_service.py index ccb2020..1da4dcd 100644 --- a/solution/backend/services/faq_service.py +++ b/solution/backend/services/faq_service.py @@ -15,7 +15,7 @@ import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import faqs, places +from common.database.model.models import place_faqs, places from common.enums import ( CANDIDATE_FACT_STATUSES, FACT_STATUS_TRANSITIONS, @@ -67,7 +67,7 @@ class FaqService: async def _reload(self, res: Res_Faq, pid, faq_id) -> Res_Faq: _e, row = await DB_SESSION_MNG.execute_lambda( - faqs.DBType(), + place_faqs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_faq(s, pid, faq_id), ) @@ -86,7 +86,7 @@ class FaqService: pid = uuid.UUID(place_id) list_err, rows = await DB_SESSION_MNG.execute_lambda( - faqs.DBType(), + place_faqs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_faqs(s, pid, publishable_only), ) @@ -123,7 +123,7 @@ class FaqService: if sort_order is None: # 순서를 안 주면 맨 뒤에 붙인다 — 기존 FAQ 사이에 끼어들어 순서를 흔들지 않게. _e, rows = await DB_SESSION_MNG.execute_lambda( - faqs.DBType(), + place_faqs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_faqs(s, pid, False), ) @@ -137,7 +137,7 @@ class FaqService: status=FactStatus.VERIFIED.value, sort_order=sort_order, ) - run_err = await DB_SESSION_MNG.execute_lambda_run([faqs.DBType()], [lambda s: self.crud.add_faq(s, row)]) + run_err = await DB_SESSION_MNG.execute_lambda_run([place_faqs.DBType()], [lambda s: self.crud.add_faq(s, row)]) if run_err != ErrorType.SUCCESS: res.result.SetResult(run_err) return res @@ -162,7 +162,7 @@ class FaqService: pid = uuid.UUID(place_id) fid = uuid.UUID(faq_id) get_err, faq = await DB_SESSION_MNG.execute_lambda( - faqs.DBType(), + place_faqs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_faq(s, pid, fid), ) @@ -192,7 +192,7 @@ class FaqService: now = GTime.UTC() run_err = await DB_SESSION_MNG.execute_lambda_run( - [faqs.DBType()], + [place_faqs.DBType()], [lambda s: self._transition_ok(s, fid, current, target, data)], ) if run_err != ErrorType.SUCCESS: diff --git a/solution/backend/services/itinerary.py b/solution/backend/services/itinerary.py new file mode 100644 index 0000000..567f3b0 --- /dev/null +++ b/solution/backend/services/itinerary.py @@ -0,0 +1,147 @@ +"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈. DB·HTTP 없음. + +빌드(payload) 시점에 그 지역의 발행된 지역정보(관광지·맛집·축제)와 업체 좌표로 +일정을 **즉석 계산**한다. 저장하지 않는 이유: 재료(local_contents)가 갱신되면 +다음 빌드에서 일정도 저절로 최신이 된다 — 따로 저장하면 그 동기화를 또 만들어야 한다. +(docs/superpowers/specs/2026-09-03-local-tourapi-sync-design.md) + +★ 지어내지 않는 규칙은 여기도 적용된다. + 재료가 부족하면 채울 수 있는 만큼만 담고, 하루도 못 채우면 일정 자체를 내지 않는다. + 좌표 없는 항목은 거리를 잴 수 없으므로 후보에서 뺀다 — 동선을 보장 못 하는 추천은 틀린 추천이다. + +하루의 뼈대: 관광지 2 + 맛집 2(점심·저녁). 진행 중 축제가 있으면 그날 관광지 한 자리를 대신한다. +일자 배분은 업체에서 가까운 순으로 후보를 끊고, 일자 안에서는 최근접 이웃 순서로 동선을 만든다. +""" +from typing import Optional + +from common.utils.geo import haversine_km + +# 하루 구성 정원. 관광지 자리는 축제가 하나 대신할 수 있다. +_SPOTS_PER_DAY = 2 +_MEALS_PER_DAY = 2 +# 후보 반경(km). 업체에서 이보다 먼 곳은 '근처'가 아니다 — 1박2일 생활권을 넘는다. +_MAX_RADIUS_KM = 30.0 + +_STOP_ATTRACTION = "attraction" +_STOP_RESTAURANT = "restaurant" +_STOP_FESTIVAL = "festival" + + +def _as_float(value) -> Optional[float]: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _candidates(rows: list[dict], stop_type: str, + base_lat: float, base_lng: float) -> list[dict]: + """payload 지역정보 행 → 거리 오름차순 후보. 좌표가 없거나 반경 밖이면 뺀다. + + 행 모양은 스냅샷 local.contents 의 body(services/external/tour_api._normalize)다 — + mapx=경도, mapy=위도 (WGS84 문자열). + """ + out = [] + for row in rows: + body = row.get("body") or {} + name = str(row.get("title") or body.get("title") or "").strip() + lat, lng = _as_float(body.get("mapy")), _as_float(body.get("mapx")) + if not name or lat is None or lng is None: + continue + dist = haversine_km(base_lat, base_lng, lat, lng) + if dist > _MAX_RADIUS_KM: + continue + out.append({"type": stop_type, "name": name, "lat": lat, "lng": lng, + "distanceKm": round(dist, 1)}) + out.sort(key=lambda c: c["distanceKm"]) + return out + + +def _order_by_route(stops: list[dict], base_lat: float, base_lng: float) -> list[dict]: + """일자 안 동선: 업체에서 출발해 최근접 이웃 순으로 잇는다.""" + remaining = list(stops) + ordered: list[dict] = [] + lat, lng = base_lat, base_lng + while remaining: + nxt = min(remaining, key=lambda s: haversine_km(lat, lng, s["lat"], s["lng"])) + remaining.remove(nxt) + ordered.append(nxt) + lat, lng = nxt["lat"], nxt["lng"] + return ordered + + +def _take(pool: list[dict], count: int) -> list[dict]: + taken, pool[:] = pool[:count], pool[count:] + return taken + + +# 일자 이름. 화면이 탭 라벨로 그대로 쓴다. +_DAY_LABEL = {1: "첫째 날", 2: "둘째 날", 3: "셋째 날"} +# 며칠짜리인가 → 사람이 읽는 말. 화면은 이 값으로 일정을 가른다(ItineraryItem.duration). +_DURATION = {2: "1박 2일", 3: "2박 3일"} + + +def _to_stop(cand: dict) -> dict: + """후보 → 렌더러의 PlannerStop. + + ★ 머무는 시간·이동 시간은 **비운다.** 우리가 재지 않은 값이라, 넣으면 화면의 시각표가 + 지어낸 숫자 위에 세워진다. 화면은 없으면 자기 기본값으로 계산한다. + ★ URL 을 만들지 않는다 — 지도 검색어(searchQuery)만 준다(LocalPlace 와 같은 규약). + """ + return { + "name": cand["name"], + "searchQuery": cand["name"], + "latitude": cand["lat"], + "longitude": cand["lng"], + } + + +def _plan_days(days: int, attractions: list[dict], restaurants: list[dict], + festivals: list[dict], base_lat: float, base_lng: float) -> Optional[dict]: + """일자별 계획. 첫날 하루도 못 채우면 None — 반쪽짜리 일정은 내지 않는다.""" + spots = list(attractions) + meals = list(restaurants) + fests = list(festivals) + plan = [] + for day in range(1, days + 1): + day_stops = [] + # 축제는 하루 하나까지, 관광지 한 자리를 대신한다. + fest = _take(fests, 1) + day_stops += fest + day_stops += _take(spots, _SPOTS_PER_DAY - len(fest)) + day_stops += _take(meals, _MEALS_PER_DAY) + if not day_stops: + break + plan.append({ + "label": _DAY_LABEL.get(day, f"{day}일차"), + "stops": [_to_stop(s) for s in _order_by_route(day_stops, base_lat, base_lng)], + }) + if not plan: + return None + # ★ 렌더러 계약(`shared` ItineraryItem)의 모양으로 낸다. 예전에는 {days, plan[{day, stops}]} + # 라는 우리끼리의 모양이었고, 화면(ItinerarySection)은 그걸 못 읽어 **일정이 통째로 + # 안 나왔다** — 수집·계산은 다 됐는데 화면만 비어 있었다(실측 2026-09-09, 조이모텔). + return {"name": _DURATION[days], "duration": _DURATION[days], "days": plan} + + +def build_itineraries(base_lat: Optional[float], base_lng: Optional[float], + attractions: list[dict], restaurants: list[dict], + festivals: list[dict]) -> list[dict]: + """업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정. 좌표가 없으면 빈 배열. + + 입력 행 모양은 스냅샷 local.contents 항목({title, body:{mapx, mapy, …}})이다. + """ + if base_lat is None or base_lng is None: + return [] + spot_pool = _candidates(attractions, _STOP_ATTRACTION, base_lat, base_lng) + meal_pool = _candidates(restaurants, _STOP_RESTAURANT, base_lat, base_lng) + fest_pool = _candidates(festivals, _STOP_FESTIVAL, base_lat, base_lng) + + out = [] + for days in (2, 3): + # 세트마다 독립된 풀 복사 — 1박2일이 소비한 후보가 2박3일에서 빠지면 안 된다. + built = _plan_days(days, list(spot_pool), list(meal_pool), list(fest_pool), + base_lat, base_lng) + if built: + out.append(built) + return out diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index 071779d..a73e85e 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -1,32 +1,142 @@ -from datetime import date, datetime, timedelta, timezone +import re +import uuid +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +from sqlalchemy import select from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import local_contents -from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource +from common.database.model.models import area_contents, place_area_refs, place_channels, places +from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource, PlaceCategory +from common.logger import LOG from crud.local_content_crud import LocalContentCRUD -from router.v1.local.protocol import ResLocalContentList, ResSyncFestivals, ResWeather, WeatherData +from crud.place_content_crud import PlaceContentCRUD +from crud.place_crud import PlaceCRUD +from services.external.kakao import KakaoLocalClient, KakaoNotConfigured, KakaoRequestFailed +from services.external.naver import region_key +from router.v1.local.protocol import ( + ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, WeatherData, +) +from services.external import tour_api from services.external.open_meteo import OpenMeteoRequestFailed, fetch_current_weather -from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed, search_festivals +from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed +from services.place_category import guess_food_class + +# collect_service.discover_tour_api 가 등록하는 업장 자신의 TourAPI 링크. (contentTypeId, contentId) +_TOUR_LINK = re.compile(r"^tour://(\d+)/(\d+)$", re.I) +# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백. 카페는 카페(FD05)를 뺀다. +# 음식점은 어떤 음식인지 모르면 아무것도 빼지 않는다(한식당에서 양식집을 빼면 안 된다). +_DEFAULT_FOOD_CLASS = {PlaceCategory.CAFE.value: "FD05"} + +# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대. 행사 날짜는 한국 날짜다. +_KST = timezone(timedelta(hours=9)) + +# 업장 반경(m). 2026-09-04 실측(군산 절골길 18)으로 정했다 — specs/2026-09-04-tourapi-radius-spike.md +# 관광지·축제·여행코스 10km: 5km 는 관광지 16건, 10km 는 38건. 원도심 밖 명소가 10km 에서 잡힌다. +# 맛집 5km: 10km 에서도 66건 중 59건이 5km 안이다. 밥은 동네에서 먹는다. +# 종류마다 반경이 다르다(2026-09-08) — 걸어갈 맛집과 차로 갈 관광지를 같은 반경으로 재지 않는다. +# 축제는 반경이 없다 — 업장이 속한 시도 전체를 그대로 싣는다(tour_api.fetch_festivals_in_sido). +RESTAURANT_RADIUS_M = 5_000 +ATTRACTION_RADIUS_M = 10_000 + + +def _festival_display_end(body: dict) -> datetime | None: + """eventenddate(YYYYMMDD) → 그 날 KST 자정(다음날 00:00) UTC. + + ★ 이 값이 있어야 끝난 축제가 발행본에서 저절로 빠진다 — + 스냅샷의 노출창 필터(snapshot._local_contents)가 display_end_at 을 본다.""" + raw = str(body.get("eventenddate") or "").strip() + if len(raw) != 8 or not raw.isdigit(): + return None + try: + end_day = datetime(int(raw[:4]), int(raw[4:6]), int(raw[6:]), tzinfo=_KST) + except ValueError: + return None + return (end_day + timedelta(days=1)).astimezone(timezone.utc) + + +def _as_float(value) -> float | None: + try: + return float(value) if value is not None else None + except (TypeError, ValueError): + return None class LocalContentService: def __init__(self): self.crud = LocalContentCRUD() + self.place_crud = PlaceContentCRUD() async def list(self, status=None, region_code=None): res = ResLocalContentList() err, rows = await DB_SESSION_MNG.execute_lambda( - local_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list(s, status, region_code) + area_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list(s, status, region_code) ) res.result.SetResult(err) res.contents = list(rows) if err == ErrorType.SUCCESS else [] return res - async def sync_festivals(self, req): - res = ResSyncFestivals() + # ── 업장 반경 주변정보 ─────────────────────────────────────────────── + + async def sync_place(self, place) -> ResSyncPlace: + """업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_area_refs 를 맞춘다. + 종류마다 따로 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 2026-09-08). + 여행코스(25)는 뺐다 — 반경을 넓혀도 데이터가 거의 없다(전북 전체 3건 실측). + + 빌드가 매번 부른다(services/build_service.run_build) — 발행본은 정적이라 이때 채운 값이 실린다. + ★ 실패해도 기존 행을 지우지 않는다 — 직전 값 유지가 이 캐시의 규약이다(모델 주석). + ★ 응답에 없는 행은 소프트 삭제한다 — 반경 밖으로 밀렸거나 TourAPI 가 내린 것이다. + hidden(운영자 숨김)은 재수집이 덮어쓰지 않는다(crud.upsert). 이 변경으로 기존에 저장된 + 여행코스 행도 다음 재수집 때 자연스레 소프트 삭제된다(더는 keep 목록에 없으므로). + ★ 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정). 틀린 항목은 운영자가 숨긴다. + """ + res = ResSyncPlace() + place_id = getattr(place, "place_id", None) + if place_id is None: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None)) + if lat is None or lng is None: + # ★ 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색). 주변 정보는 TourAPI 에 이 업소가 + # 등록돼 있느냐와 무관하다 — 필요한 건 좌표뿐이다. 찾으면 places 에 박제해 다음부터는 안 부른다. + found = await self._geocode_and_store(place) + if found is None: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + res.msg = "사업장 좌표가 없어 주변 정보를 받을 수 없습니다(주소로도 찾지 못함)." + return res + lat, lng = found + try: - start = datetime.strptime(req.start_date, "%Y%m%d").date() if req.start_date else date.today() - items = await search_festivals(start_date=start, area_code=req.area_code) + async with tour_api.make_client() as client: + # 종류마다 반경이 달라 따로 부른다 — 맛집은 걸어갈 거리, 축제는 차로 갈 거리다. + restaurants = await tour_api.fetch_nearby( + client, lat, lng, radius_m=RESTAURANT_RADIUS_M, content_type_id="39") + # ★ 업종별 제외(2026-09-08 결정): 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 — + # 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다. + # 숙박 업장은 숙박(32)을 빼야 하는데 애초에 요청하지 않으므로 여기서 할 일이 없다. + own_class = await self._own_food_class(client, place) + if own_class: + before = len(restaurants) + restaurants = [r for r in restaurants if r.get("lclsSystm2") != own_class] + LOG.i(f"[local] place={place_id} 같은 업태({own_class}) 맛집 {before - len(restaurants)}건 제외") + attractions = await tour_api.fetch_nearby( + client, lat, lng, radius_m=ATTRACTION_RADIUS_M, content_type_id="12") + + # ★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다(2026-09-08 교체) — + # 위치 색인을 못 믿는다(실측: 반경 20km 를 넓혀도 몇 년 전에 끝난 전시만 잡히고, + # 500m 옆 진행 예정 축제는 안 잡혔다). 시도 코드가 있어야 부를 수 있다. + sido_code = str(getattr(place, "region_code", None) or "")[:2] or None + if not sido_code: + address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "") + derived = region_key(address) + sido_code = derived[:2] if derived else None + today = datetime.now(_KST).date() + if sido_code: + festivals = await tour_api.fetch_festivals_in_sido( + client, lat, lng, sido_code=sido_code, today=today) + else: + LOG.w(f"[local] place={place_id} 시도 코드를 못 구해 축제는 건너뜀") + festivals = [] except TourApiNotConfigured: res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED) res.msg = "TOUR_API_KEY를 먼저 설정해주세요." @@ -36,39 +146,236 @@ class LocalContentService: res.msg = str(ex) return res - collected = skipped = 0 - for item in items: - external_id = str(item.get("contentid") or "") - if not external_id: - skipped += 1 - continue - _, existing = await DB_SESSION_MNG.execute_lambda( - local_contents.DBType(), DBWRType.DB_READ.value, - lambda s, eid=external_id: self.crud.get_by_external_id(s, req.region_code, eid), + kept: list[dict] = restaurants + attractions + festivals + + err, rows = await DB_SESSION_MNG.execute_lambda( + place_area_refs.DBType(), DBWRType.DB_READ.value, + lambda s: self.place_crud.list_by_place(s, place_id), + ) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + existing = {(int(r.content_type), r.external_id): r for r in (rows or [])} # 조인 결과(area_contents + 거리) + + now = datetime.now(timezone.utc) + # 공용 콘텐츠에 실어 둘 지역. 유일성의 근거는 external_id 이고 이건 조회 편의다 — + # 없으면 NULL 로 둔다(지어내지 않는다). + region_code = str(getattr(place, "region_code", None) or "").strip() or None + changed = 0 + kept_ids: set = set() + for body in kept: + key = (body["content_type"], body["contentid"]) + prev = existing.get(key) + # ★ 실체는 전국 공용이다 — 다른 업장이 이미 넣어 뒀으면 그 행을 그대로 쓴다. + # (source, external_id) 로 upsert 하고 돌려받은 id 로 관계만 잇는다. + content_values = { + "source": LocalSource.TOUR_API.value, + "external_id": body["contentid"], + "content_type": body["content_type"], + "title": body["title"], + "body": body, + "latitude": _as_float(body.get("mapy")), + "longitude": _as_float(body.get("mapx")), + "region_code": region_code, + # ★ has_image 컬럼은 두지 않는다 — body.firstimage 가 이미 그 사실이다. + # 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다. + "status": LocalContentStatus.PUBLISHED.value, + "display_end_at": ( + _festival_display_end(body) if body["content_type"] == LocalContentType.FESTIVAL.value else None + ), + "collected_at": now, + } + write_err = await DB_SESSION_MNG.execute_lambda_run( + [area_contents.DBType()], + [lambda s, v=content_values: self.place_crud.upsert_content(s, v)], ) - if existing: - skipped += 1 + if write_err != ErrorType.SUCCESS: continue - row = local_contents( - region_code=req.region_code, - content_type=LocalContentType.FESTIVAL.value, - source=LocalSource.TOUR_API.value, - external_id=external_id, - title=item.get("title"), - body=item, - status=LocalContentStatus.REVIEW.value, - collected_at=datetime.now(timezone.utc), + err_i, rows_i = await DB_SESSION_MNG.execute_lambda( + area_contents.DBType(), DBWRType.DB_READ.value, + lambda s, e=body["contentid"]: self.place_crud.find_content_id(s, LocalSource.TOUR_API.value, e), ) - err = await DB_SESSION_MNG.execute_lambda_run([row.DBType()], [lambda s, r=row: self.crud.insert(s, r)]) - collected += int(err == ErrorType.SUCCESS) - skipped += int(err != ErrorType.SUCCESS) - res.collected, res.skipped = collected, skipped + if err_i != ErrorType.SUCCESS or not rows_i: + continue + content_id = rows_i[0] # 단일 컬럼 SELECT 는 scalars() 로 펴져 값이 곧 행이다 + kept_ids.add(content_id) + + # 원문이 그대로면 관계만 확인하고 넘어간다 — collected_at 만 밀리면 '갱신된 척'이 된다. + await DB_SESSION_MNG.execute_lambda_run( + [place_area_refs.DBType()], + [lambda s, cid=content_id, d=body["distance_m"]: self.place_crud.upsert_ref(s, place_id, cid, d)], + ) + if prev is None or prev.body != body: + changed += 1 + + # 이번 응답에 없는 **관계**만 끊는다. 실체는 남긴다 — 다른 업장이 가리키고 있을 수 있다. + _, removed = await DB_SESSION_MNG.execute_lambda_claim( + place_area_refs.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, kept_ids) + ) + + counts = {k: 0 for k in (LocalContentType.FESTIVAL.value, LocalContentType.ATTRACTION.value, + LocalContentType.RESTAURANT.value, LocalContentType.COURSE.value)} + for b in kept: + counts[b["content_type"]] += 1 + res.festivals = counts[LocalContentType.FESTIVAL.value] + res.attractions = counts[LocalContentType.ATTRACTION.value] + res.restaurants = counts[LocalContentType.RESTAURANT.value] + res.courses = counts[LocalContentType.COURSE.value] + res.changed = changed > 0 or bool(removed) + LOG.i(f"[local] place={place_id} 주변정보 {len(kept)}건(갱신 {changed} · 제거 {removed or 0})") return res + async def _own_food_class(self, client, place) -> str | None: + """음식점·카페 업장 자신의 TourAPI 중분류(FD01~FD05). 숙박·병원은 None(제외할 게 없다). + + 우선순위 — 정확한 쪽부터: + 1. 업장이 TourAPI 에 등록돼 있으면(place_channels 의 tour:// 링크) 그 콘텐츠의 lclsSystm2. + 주변 항목과 **같은 체계**라 오차가 없다. 링크는 수집(COLLECT)이 상호+좌표 검증을 거쳐 붙인다. + 2. 검증 때 박제한 외부 분류 문자열(places.external_category)을 키워드로 매핑. + 3. 그래도 모르면 업종 기본값 — 카페는 FD05. 음식점은 None(무엇을 빼야 할지 모른다). + 실패는 전부 '제외 없음'으로 떨어진다 — 경쟁 업소가 섞이는 것이 맛집 섹션이 통째로 비는 것보다 낫다. + """ + category = getattr(place, "category", None) + if category not in (PlaceCategory.CAFE.value, PlaceCategory.RESTAURANT.value): + return None + + err, links = await DB_SESSION_MNG.execute_lambda( + place_channels.DBType(), DBWRType.DB_READ.value, + lambda s: PlaceCRUD().list_links(s, place.place_id, confirmed_only=True), + ) + for link in (links or []) if err == ErrorType.SUCCESS else []: + m = _TOUR_LINK.match(str(getattr(link, "url", "") or "")) + if not m: + continue + try: + code = await tour_api.fetch_content_class(client, m.group(2)) + except TourApiRequestFailed as ex: + LOG.w(f"[local] 업장 TourAPI 분류 조회 실패(외부 분류로 폴백): {ex}") + code = None + if code: + return code + break + + guessed = guess_food_class(getattr(place, "external_category", None)) + if guessed: + return guessed + return _DEFAULT_FOOD_CLASS.get(category) + + async def _geocode_and_store(self, place) -> tuple[float, float] | None: + """주소 → 좌표(카카오). 찾으면 places.latitude/longitude 에 박제한다. 키가 없거나 실패하면 None.""" + address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip() + if not address: + return None + client = KakaoLocalClient() + if not client.enabled: + LOG.w("[local] 좌표 없는 사업장인데 KAKAO_REST_API_KEY 가 없어 주소로 찾지 못한다") + return None + try: + found = await client.geocode_address(address) + except (KakaoNotConfigured, KakaoRequestFailed) as ex: + LOG.w(f"[local] 주소 → 좌표 실패(계속): {ex}") + return None + finally: + await client.aclose() + if found is None: + return None + + lat, lng = found + company_id = getattr(place, "company_id", None) + if company_id is not None: + data = {"latitude": Decimal(str(lat)), "longitude": Decimal(str(lng))} + err, _ = await DB_SESSION_MNG.execute_lambda_claim( + places.DBType(), + lambda s: PlaceCRUD().update_place(s, company_id, place.place_id, data), + ) + if err != ErrorType.SUCCESS: + LOG.w(f"[local] 좌표 박제 실패(이번 수집엔 그대로 씀): {err.name}") + else: + place.latitude, place.longitude = data["latitude"], data["longitude"] + LOG.i(f"[local] place={place.place_id} 주소로 좌표 확보 ({lat:.5f}, {lng:.5f}) — {address[:40]}") + return lat, lng + + async def _load_place(self, place_id): + """회사 스코프 없이 사업장 1건. ★ 공개 조회(guide)와 운영자 화면이 쓴다 — 사장님 API 는 place_service 를 탄다.""" + err, rows = await DB_SESSION_MNG.execute_lambda( + places.DBType(), DBWRType.DB_READ.value, + lambda s: DB_SESSION_MNG.execute( + s, select(places).where(places.place_id == place_id, places.deleted == False).limit(1) # noqa: E712 + ), + ) + return (rows[0] if rows else None) if err == ErrorType.SUCCESS else None + + async def sync_place_by_id(self, place_id: uuid.UUID) -> ResSyncPlace: + place = await self._load_place(place_id) + if place is None: + res = ResSyncPlace() + res.result.SetResult(ErrorType.DB_EMPTY_DATA) + return res + return await self.sync_place(place) + + async def list_place_contents(self, place_id: uuid.UUID) -> ResPlaceContentList: + res = ResPlaceContentList() + err, rows = await DB_SESSION_MNG.execute_lambda( + place_area_refs.DBType(), DBWRType.DB_READ.value, + lambda s: self.place_crud.list_by_place(s, place_id, include_hidden=True), + ) + res.result.SetResult(err) + res.contents = list(rows) if err == ErrorType.SUCCESS else [] + return res + + async def set_hidden(self, place_content_id: uuid.UUID, hidden: bool) -> ResPlaceContentList: + res = ResPlaceContentList() + err, count = await DB_SESSION_MNG.execute_lambda_claim( + place_area_refs.DBType(), lambda s: self.place_crud.set_hidden(s, place_content_id, hidden) + ) + res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA) + return res + + async def get_guide(self, place_id: uuid.UUID) -> ResLocalGuide: + """에디터 캔버스용 주변 가이드(맛집·명소·축제·코스). + + ★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 — + 캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다. + 그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다. + ★ 일정(itineraries)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다. + """ + # 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다. + from services.site_payload import _local + from services.snapshot import _local_contents + + res = ResLocalGuide() + place = await self._load_place(place_id) + if place is None: + res.result.SetResult(ErrorType.DB_EMPTY_DATA) + return res + + # ★ 아직 한 번도 수집하지 않은 사업장은 **지금** 채운다(cache-aside — 날씨와 같은 규약). + # 빌드 때만 채우면 방금 만든 사업장은 첫 빌드 전까지 캔버스가 계속 "준비 중"이다(2026-09-07 실측). + # 행이 하나라도 있으면 부르지 않는다 — 갱신은 빌드·운영자 재수집이 맡는다. + err, rows = await DB_SESSION_MNG.execute_lambda( + place_area_refs.DBType(), DBWRType.DB_READ.value, + lambda s: self.place_crud.list_by_place(s, place_id), + ) + if err == ErrorType.SUCCESS and not rows: + synced = await self.sync_place(place) + if not synced.result.success: + LOG.w(f"[local] place={place_id} 첫 조회 수집 실패(빈 채로 응답): {synced.msg}") + + snapshot_local = await _local_contents(place) + local, synced_at = _local(snapshot_local, _as_float(place.latitude), _as_float(place.longitude)) + res.attractions = local.get("attractions") or [] + res.restaurants = local.get("restaurants") or [] + res.festivals = local.get("festivals") or [] + res.courses = local.get("courses") or [] + res.synced_at = synced_at + return res + + # ── 지역 캐시(area_contents) — 운영자 수기 항목·날씨 ──────────────── + async def publish(self, ids, user_id): res = ResLocalContentList() err, _ = await DB_SESSION_MNG.execute_lambda_claim( - local_contents.DBType(), lambda s: self.crud.publish(s, ids, user_id) + area_contents.DBType(), lambda s: self.crud.publish(s, ids, user_id) ) res.result.SetResult(err) return res @@ -82,7 +389,7 @@ class LocalContentService: res.msg = "노출 종료는 시작보다 뒤여야 합니다." return res err, count = await DB_SESSION_MNG.execute_lambda_claim( - local_contents.DBType(), lambda s: self.crud.update(s, content_id, data) + area_contents.DBType(), lambda s: self.crud.update(s, content_id, data) ) res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA) return res @@ -90,7 +397,7 @@ class LocalContentService: async def end(self, content_id): res = ResLocalContentList() err, count = await DB_SESSION_MNG.execute_lambda_claim( - local_contents.DBType(), lambda s: self.crud.end(s, content_id) + area_contents.DBType(), lambda s: self.crud.end(s, content_id) ) res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA) return res @@ -99,7 +406,7 @@ class LocalContentService: """Cache-aside weather lookup; stale data survives upstream failures.""" res = ResWeather() err, cached = await DB_SESSION_MNG.execute_lambda( - local_contents.DBType(), DBWRType.DB_READ.value, + area_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_weather(s, region_code), ) if err != ErrorType.SUCCESS: @@ -139,7 +446,7 @@ class LocalContentService: "updated_at": now, } write_err = await DB_SESSION_MNG.execute_lambda_run( - [local_contents.DBType()], [lambda s: self.crud.upsert_weather(s, values)] + [area_contents.DBType()], [lambda s: self.crud.upsert_weather(s, values)] ) if write_err != ErrorType.SUCCESS: res.result.SetResult(write_err) diff --git a/solution/backend/services/media_service.py b/solution/backend/services/media_service.py index b423ca6..1f40c1b 100644 --- a/solution/backend/services/media_service.py +++ b/solution/backend/services/media_service.py @@ -3,7 +3,7 @@ import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import media, places +from common.database.model.models import place_photos, places from common.enums import DBWRType, ErrorType, MediaStatus, SourceType from common.models.gmodel import UserInfo from crud.media_crud import IMediaCRUD, MediaCRUD @@ -66,7 +66,7 @@ class MediaService: # publishable_only 의 승인 조건만 CRUD 에 넘기고, alt 조건은 목록 계산과 함께 아래에서 건다. status = MediaStatus.APPROVED.value if publishable_only else None list_err, rows = await DB_SESSION_MNG.execute_lambda( - media.DBType(), + place_photos.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_media( s, uuid.UUID(place_id), status, False, unit_id, publishable_only @@ -81,7 +81,7 @@ class MediaService: data = MediaData.model_validate(r) data.publishable = _is_publishable(r) items.append(data) - res.media = items + res.place_photos = items res.publishable = sum(1 for x in items if x.publishable) # 사람 확인 큐에 남은 수 — 관리 화면의 '검토할 것' 배지. res.pending_review = sum(1 for r in rows if r.status == MediaStatus.PENDING_REVIEW.value) diff --git a/solution/backend/services/place_category.py b/solution/backend/services/place_category.py index 950b662..25db0da 100644 --- a/solution/backend/services/place_category.py +++ b/solution/backend/services/place_category.py @@ -68,3 +68,39 @@ def _match(text: str) -> Optional[PlaceCategory]: if any(word in text for word in words): return category return None + + +# ── 음식 중분류(TourAPI lclsSystm2) 추정 ───────────────────────────────── +# 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준이다(2026-09-08 결정). +# TourAPI 분류체계(lclsSystmCode2 실측) — FD01 한식 · FD02 외국식(중·일·서양·기타외국·퓨전) +# · FD03 간이음식(제과·피자/햄버거/샌드위치·치킨·김밥분식·이동음식) · FD04 주점 · FD05 카페/찻집. +# +# ★ 순서가 결과를 바꾼다. 카카오는 카페를 "음식점 > 카페 > …" 아래 두므로 "음식점"이 항상 붙어 있다 — +# 그래서 "음식점" 은 판정어로 쓰지 않고, 구체적인 업태(카페·주점·간이·외국식)를 한식보다 먼저 본다. +# ★ 한식은 마지막이고 판정어가 좁다("한식"·"한정식"·"백반"·"국밥"…). 고기·회 같은 재료명은 넣지 않는다 — +# "양식 > 스테이크" 를 고기라고 한식으로 넣으면 서양식 스테이크집이 한식이 된다. +_FOOD_CLASS_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [ + ("FD05", ("카페", "커피", "찻집", "디저트", "음료", "주스", "빙수", "케이크", "브런치")), + ("FD04", ("주점", "술집", "호프", "맥주", "이자카야", "포차", "와인바", "칵테일", "펍")), + ("FD03", ("제과", "베이커리", "빵", "도넛", "피자", "햄버거", "샌드위치", "치킨", "분식", "김밥", + "떡볶이", "간식", "토스트", "패스트푸드")), + ("FD02", ("중식", "중국", "일식", "일본", "초밥", "라멘", "양식", "서양", "이탈리", "파스타", "스테이크", + "프렌치", "프랑스", "멕시", "아시아", "베트남", "태국", "인도", "퓨전")), + ("FD01", ("한식", "한정식", "백반", "국밥", "찌개", "국수", "냉면", "삼겹", "갈비", "곱창", "족발", "보쌈", + "해장국", "설렁탕", "감자탕", "칼국수", "횟집", "회")), +] + + +def guess_food_class(category_name: Optional[str]) -> Optional[str]: + """외부 분류 문자열 → TourAPI 음식 중분류 코드(FD01~FD05). 모르면 None(제외 없음). + + 예 — 카카오 "음식점 > 카페 > 커피전문점" → FD05 · 네이버 "카페,디저트" → FD05 · + 카카오 "음식점 > 한식 > 육류,고기" → FD01 · "음식점 > 양식 > 스테이크,립" → FD02 + """ + text = (category_name or "").replace(" ", "") + if not text: + return None + for code, words in _FOOD_CLASS_KEYWORDS: + if any(word in text for word in words): + return code + return None diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index 9c7d7c5..34227f4 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -4,7 +4,7 @@ from fastapi import Depends from common.category_schema import CategorySchemaError, get_schema from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import place_links, places, units +from common.database.model.models import place_channels, places, place_units from common.enums import DBWRType, ErrorType, JobStatus, JobType, LinkChannel, PlaceCategory, PlaceStatus, SourceType from common.logger import LOG from common.models.gmodel import PageParams, UserInfo @@ -243,6 +243,8 @@ class PlaceService: phone=base.get("phone") or base.get("virtualPhone") or None, latitude=Decimal(str(coord.get("y"))) if coord.get("y") else None, longitude=Decimal(str(coord.get("x"))) if coord.get("x") else None, + # 네이버 상세의 분류("펜션"·"카페,디저트"). 주변 맛집 경쟁업소 제외의 폴백 근거(실측 2026-09-08: 있음). + category_name=str(base.get("category") or "").strip() or None, ) verified = await self.verify_place(user_info, place_id, verify_req) if not verified.result.success: @@ -268,7 +270,7 @@ class PlaceService: title=f"{official} 네이버 플레이스", discovered_by=SourceType.OWNER), ) await DB_SESSION_MNG.execute_lambda_claim( - place_links.DBType(), + place_channels.DBType(), lambda s: self.crud.confirm_link_by_url( s, uuid.UUID(place_id), canonical, uuid.UUID(user_info.user_id), GTime.UTC() ), @@ -321,6 +323,9 @@ class PlaceService: "verified_at": now, "verified_by": uuid.UUID(user_info.user_id), } + # ★ 값이 왔을 때만 덮는다 — 네이버 URL 재검증이 분류를 못 읽었다고 카카오가 준 값을 지우면 안 된다. + if (req.category_name or "").strip(): + data["external_category"] = req.category_name.strip()[:200] err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: self.crud.update_place(s, uid, uuid.UUID(place_id), data), @@ -345,7 +350,7 @@ class PlaceService: discovered_at=now, ) await DB_SESSION_MNG.execute_lambda_run( - [place_links.DBType()], + [place_channels.DBType()], [lambda s: self.crud.add_link(s, link)], ) return await self.get_place(user_info, place_id) @@ -358,7 +363,7 @@ class PlaceService: res.result.SetResult(err_type) return res list_err, rows = await DB_SESSION_MNG.execute_lambda( - units.DBType(), + place_units.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_units(s, uuid.UUID(place_id)), ) @@ -380,7 +385,7 @@ class PlaceService: unit = units(place_id=uuid.UUID(place_id), name=req.name.strip(), sort_order=req.sort_order) run_err = await DB_SESSION_MNG.execute_lambda_run( - [units.DBType()], + [place_units.DBType()], [lambda s: self.crud.add_unit(s, unit)], ) if run_err != ErrorType.SUCCESS: @@ -397,7 +402,7 @@ class PlaceService: res.result.SetResult(err_type) return res list_err, rows = await DB_SESSION_MNG.execute_lambda( - place_links.DBType(), + place_channels.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_links(s, uuid.UUID(place_id), confirmed_only), ) @@ -430,7 +435,7 @@ class PlaceService: discovered_at=GTime.UTC(), ) run_err = await DB_SESSION_MNG.execute_lambda_run( - [place_links.DBType()], + [place_channels.DBType()], [lambda s: self.crud.add_link(s, link)], ) if run_err != ErrorType.SUCCESS: @@ -451,7 +456,7 @@ class PlaceService: return res run_err, rowcount = await DB_SESSION_MNG.execute_lambda_claim( - place_links.DBType(), + place_channels.DBType(), lambda s: self.crud.confirm_link( s, uuid.UUID(place_id), uuid.UUID(link_id), uuid.UUID(user_info.user_id), GTime.UTC() ), @@ -492,7 +497,7 @@ class PlaceService: # 채널 URL 발견(Perplexity)은 **잡의 첫 단계**다 — 링크가 하나도 없어도 수집을 시작할 수 있다. # 여기서는 이미 확정된 링크 수만 세어 응답에 실어준다(진행 상황 표시용). link_err, links = await DB_SESSION_MNG.execute_lambda( - place_links.DBType(), + place_channels.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_links(s, uuid.UUID(place_id), True), ) @@ -554,7 +559,7 @@ class PlaceService: force 로 재분석할 때 이 엔드포인트를 쓴다.""" from sqlalchemy import func, select - from common.database.model.models import media + from common.database.model.models import place_photos from services.external import gemini res = Res_StartVision() @@ -817,7 +822,7 @@ class PlaceService: ★ 근거로 쓸 확인된 fact 가 없으면 잡을 만들지 않는다 — 근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다.""" - from common.database.model.models import facts as facts_model + from common.database.model.models import place_facts as facts_model from crud.fact_crud import FactCRUD from services.external import gemini_text diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index cd5790b..b992ac7 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -31,6 +31,7 @@ from common.enums import ( SourceType, ) from common.logger import LOG +from services.itinerary import build_itineraries # 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다. SCHEMA_VERSION = 1 @@ -65,6 +66,26 @@ _CHANNEL_TITLE = { LinkChannel.ETC.value: "기타 채널", } +# 저장된 look 이 없을 때 쓰는 기본 생김새 — 에디터의 '심플' 템플릿 +# (`solution/frontend/src/data/industryData.ts` 의 LOOK.simple)과 같은 값이다. +# ★ 왜 필요한가 (실측 2026-09-08, `/s/stay-mumum-gunsan`) +# 이 키가 없으면 `` 에 --tpl-font-heading·--tpl-radius·--tpl-texture 가 아예 +# 안 실리고, 발행본은 렌더러 CSS 의 폴백으로 떨어진다. 그 폴백의 제목 서체는 +# `--font-serif`(명조)다 — 그래서 위저드를 안 돈 사업장의 발행본만 제목이 명조로, +# 모서리는 렌더러 기본값으로 나가 에디터 미리보기(고딕)와 눈에 띄게 갈렸다. +# 색은 업종 기본이 있는데 생김새만 없어서 생긴 구멍이라, 고르지 않았을 때의 모습도 정해 둔다. +_DEFAULT_LOOK = { + "fontHeading": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", + "fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", + "radius": "0.75rem", + "borderWidth": "1px", + "shadow": "0 1px 2px rgb(0 0 0 / 0.06)", + "headingTracking": "-0.02em", + "headingWeight": "700", + "sectionSpace": "4rem", +} + + # 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다. # ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다: # templateId ← sites.template_id (POST /v1/place/{id}/site/template) @@ -84,11 +105,22 @@ _DEFAULT_THEME = { "fontStyle": "Modern Editorial", "colors": {"primary": "#18181b", "secondary": "#52525b", "bg": "#ffffff", "card": "#fafafa", "text": "#09090b", "accent": "#2563eb"}, + # ★ 순서·구성이 시안(/s/stay)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은 + # 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다. + # ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사· + # 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection). + # ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데 + # (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라 + # 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다. "sections": [ ("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False), - ("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "예약 안내", False), - ("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False), - ("local", "지역 정보", False), ("faq", "자주 묻는 질문", False), + ("event", "소식", False), + ("info", "기본 정보", True), ("booking", "예약 안내", False), + ("video", "영상", False), + ("photos", "사진 갤러리", False), ("map", "오시는 길", True), + ("festival", "계절별 축제", False), ("local", "지역 정보", False), + ("itinerary", "추천 일정", False), ("story", "지역 이야기", False), + ("faq", "자주 묻는 질문", False), ("weather", "날씨", False), ], }, PlaceCategory.CAFE.value: { @@ -313,9 +345,15 @@ def _theme(site, theme_spec: dict) -> dict: # ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 — # 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다. # 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다. + # 저장된 look 이 있으면 그게 이긴다. 다만 **덮어쓰기가 아니라 덧칠이다** — 프론트가 + # 일부 키만 보낸 옛 저장값에 빈칸이 생기면 그 칸만 명조·렌더러 기본값으로 떨어진다. look = saved.get("look") - if isinstance(look, dict) and look: - out["look"] = {k: v for k, v in look.items() if isinstance(v, str) and v.strip()} + cleaned = ( + {k: v for k, v in look.items() if isinstance(v, str) and v.strip()} + if isinstance(look, dict) + else {} + ) + out["look"] = {**_DEFAULT_LOOK, **cleaned} return out @@ -477,32 +515,77 @@ def _festival(row: dict): # 공식 홈페이지는 출처가 준 값일 때만 싣는다. 형식이 URL 이 아니면 링크로 걸지 않는다. if homepage.startswith("http://") or homepage.startswith("https://"): entry["officialUrl"] = homepage + # 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와 + # 같은 모양으로 그리려면 필요하다(2026-09-07, 도보 시간 필터 형식 결정). + _put_distance(entry, body) + image = _text(body.get("firstimage")) + if image: + entry["imageUrl"] = image return entry def _local_place(row: dict, category: str): """LocalPlace(주변 명소·맛집). - ★ title 컬럼만 믿는다. 이 두 종류(ATTRACTION·RESTAURANT)를 채우는 수집기가 아직 없어서 - body 의 모양이 정해지지 않았다 — 있지도 않은 키를 가정해 파싱하면 수집기가 붙는 날 - 조용히 빈 값이 나간다. 지금은 테이블 스키마가 보장하는 것(title)만 쓰고, - 설명·거리는 실제 수집기가 붙을 때 그 모양을 보고 채운다.""" + body 는 TourAPI 수집기가 채운다(services/external/tour_api._normalize) — + 주소(addr1)가 있으면 위치로 싣는다. 없는 값은 만들지 않는다.""" name = _text(row.get("title")) if not name: return None - return {"name": name, "category": category, "searchQuery": name} + body = row.get("body") or {} + entry = {"name": name, "category": category, "searchQuery": name} + location = _text(body.get("addr1")) + if location: + entry["location"] = location + # 거리는 수집 시점에 업장 좌표로 잰 값(place_contents.distance_m). 지역 캐시 항목엔 없다. + # ★ 숫자(distanceMeters)도 함께 싣는다 — 화면이 도보 시간 필터(분속 80m 환산)를 계산하려면 + # "1.2km" 같은 문자열을 다시 파싱하는 것보다 원값이 안전하다. + _put_distance(entry, body) + image = _text(body.get("firstimage")) + if image: + entry["imageUrl"] = image + return entry -def _local(snapshot_local: dict) -> tuple[dict, str | None]: +def _put_distance(entry: dict, body: dict) -> None: + """distance_m → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다.""" + distance = _distance_text(body.get("distance_m")) + if not distance: + return + entry["distanceText"] = distance + entry["distanceMeters"] = int(body.get("distance_m")) + + +def _distance_text(meters) -> str: + """850 → "850m", 1234 → "1.2km". 없으면 빈 문자열.""" + try: + m = int(meters) + except (TypeError, ValueError): + return "" + if m < 0: + return "" + if m < 1000: + return f"{m}m" + # 반올림은 '5 는 올림'으로 — f"{1.45:.1f}" 는 부동소수 탓에 1.4 가 나온다. + return f"{(m + 50) // 100 / 10:.1f}km" + + +def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) -> tuple[dict, str | None]: """스냅샷의 지역 정보 → LocalContents. ★ 스냅샷이 이미 걸렀다(PUBLISHED + 노출 기간 안). 여기서 더 거르지 않고 모양만 바꾼다 — fact·사진과 같은 분업이다. ★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도 - payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다.""" + payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다. + ★ itineraries(1박2일·2박3일 일정)는 저장하지 않고 여기서 즉석 계산한다 — + 재료가 갱신되면 다음 빌드에서 일정도 저절로 최신이 된다(services/itinerary.py). + 렌더러에 아직 이 필드의 자리가 없다 — 모르는 필드는 무시되므로 화면은 변하지 않는다.""" contents = (snapshot_local or {}).get("contents") or [] - local = {"attractions": [], "restaurants": [], "festivals": []} + # courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07). + local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []} synced_at = None + # 일정 계산용 원본 행(좌표가 body 에 있다). payload 항목은 좌표를 싣지 않으므로 따로 모은다. + raw = {"attractions": [], "restaurants": [], "festivals": []} for row in contents: if not isinstance(row, dict): @@ -523,16 +606,29 @@ def _local(snapshot_local: dict) -> tuple[dict, str | None]: entry = _festival(row) if entry: local["festivals"].append(entry) + raw["festivals"].append(row) elif content_type == LocalContentType.ATTRACTION.value: entry = _local_place(row, "관광지") if entry: local["attractions"].append(entry) + raw["attractions"].append(row) elif content_type == LocalContentType.RESTAURANT.value: entry = _local_place(row, "맛집") if entry: local["restaurants"].append(entry) + raw["restaurants"].append(row) + elif content_type == LocalContentType.COURSE.value: + entry = _local_place(row, "여행코스") + if entry: + local["courses"].append(entry) # 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다. + itineraries = build_itineraries( + base_lat, base_lng, raw["attractions"], raw["restaurants"], raw["festivals"] + ) + if itineraries: + local["itineraries"] = itineraries + return local, synced_at @@ -708,7 +804,10 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: # ★ 스냅샷에서 읽는다 — 여기서 DB 를 다시 읽으면 '스냅샷과 다른 페이지'가 나온다(파일 상단 원칙). # 지역 정보를 스냅샷에 담는 필터링은 services/snapshot._local_contents 가 한다. # 옛 스냅샷에는 "local" 키가 없다. 그때는 빈 채로 나가고, 다음 빌드에서 채워진다. - local, local_synced_at = _local(snapshot.get("local") or {}) + local, local_synced_at = _local( + snapshot.get("local") or {}, + _as_float(snap_place.get("latitude")), _as_float(snap_place.get("longitude")), + ) if local_synced_at: local["syncedAt"] = local_synced_at diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index a5fc221..da100fd 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -4,7 +4,7 @@ import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import ai_check_results, places, publish_logs, site_versions, sites +from common.database.model.models import places, site_publish_logs, site_versions, sites from common.enums import ( BuildStatus, DBWRType, @@ -585,7 +585,7 @@ class SiteService: res.result.SetResult(ErrorType.SITE_NOT_FOUND) return res l_err, rows = await DB_SESSION_MNG.execute_lambda( - publish_logs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_logs(s, site.site_id, 50) + site_publish_logs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_logs(s, site.site_id, 50) ) if l_err == ErrorType.SUCCESS: res.logs = [PublishLogData.model_validate(r) for r in rows] @@ -623,12 +623,12 @@ class SiteService: await DB_SESSION_MNG.execute_lambda_claim( sites.DBType(), lambda s: self.crud.update_site(s, site.site_id, {"status": target.value}) ) - log = publish_logs( + log = site_publish_logs( site_id=site.site_id, site_version_id=site.current_version_id, action=req.action.value, result=PublishResult.SUCCESS.value, actor_user_id=uuid.UUID(user_info.user_id), ) - await DB_SESSION_MNG.execute_lambda_run([publish_logs.DBType()], [lambda s: self.crud.add_log(s, log)]) + await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: self.crud.add_log(s, log)]) return await self.get_site(user_info, place_id) diff --git a/solution/backend/services/snapshot.py b/solution/backend/services/snapshot.py index 5cf5022..6354f0f 100644 --- a/solution/backend/services/snapshot.py +++ b/solution/backend/services/snapshot.py @@ -22,13 +22,14 @@ from sqlalchemy import or_, select from common.category_schema import get_schema from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import facts, faqs, local_contents, media, units +from common.database.model.models import place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_units from common.enums import ( PUBLISHABLE_FACT_STATUSES, DBWRType, ErrorType, FactStatus, LocalContentStatus, + LocalSource, MediaStatus, PlaceCategory, ) @@ -38,9 +39,9 @@ from services.external.naver import region_key _PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES) # 지역 정보를 종류별로 몇 건까지 박제할지. -# ★ 스냅샷은 site_versions.snapshot 에 통째로 들어간다. 지역 캐시는 region_code 단위 공용이라 -# 한 지역에 수백 건이 쌓일 수 있고, 그걸 다 박제하면 버전 행마다 그만큼이 복사된다. -# 화면(LocalGuideSection)도 그만큼 보여주지 않는다 — 최근 수집분 위주로 자른다. +# ★ 종류별(맛집·관광지·축제·코스) 노출 상한 — 화면·캔버스·발행본 모두 이 수까지만 보여준다(2026-09-07 결정). +# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다. +# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다. _LOCAL_MAX_PER_TYPE = 20 # 지역 원문(body)에서 스냅샷으로 옮기지 않는 키. @@ -56,30 +57,30 @@ async def build_snapshot(place) -> dict: schema = get_schema(category) fact_rows = await _select( - select(facts).where( - facts.place_id == pid, - facts.deleted == False, # noqa: E712 - facts.status.in_(_PUBLISHABLE), + select(place_facts).where( + place_facts.place_id == pid, + place_facts.deleted == False, # noqa: E712 + place_facts.status.in_(_PUBLISHABLE), ) ) unit_rows = await _select( - select(units).where(units.place_id == pid, units.deleted == False) # noqa: E712 - .order_by(units.sort_order.asc()) + select(place_units).where(place_units.place_id == pid, place_units.deleted == False) # noqa: E712 + .order_by(place_units.sort_order.asc()) ) faq_rows = await _select( - select(faqs).where( - faqs.place_id == pid, - faqs.deleted == False, # noqa: E712 - faqs.status.in_(_PUBLISHABLE), - ).order_by(faqs.sort_order.asc()) + select(place_faqs).where( + place_faqs.place_id == pid, + place_faqs.deleted == False, # noqa: E712 + place_faqs.status.in_(_PUBLISHABLE), + ).order_by(place_faqs.sort_order.asc()) ) # ★ 승인된 사진만. Vision 신뢰도가 낮아 확인 큐에 남은 사진은 사이트에 안 나간다. media_rows = await _select( - select(media).where( - media.place_id == pid, - media.deleted == False, # noqa: E712 - media.status == MediaStatus.APPROVED.value, - ).order_by(media.sort_order.asc()) + select(place_photos).where( + place_photos.place_id == pid, + place_photos.deleted == False, # noqa: E712 + place_photos.status == MediaStatus.APPROVED.value, + ).order_by(place_photos.sort_order.asc()) ) local_rows = await _local_contents(place) @@ -164,10 +165,14 @@ async def build_snapshot(place) -> dict: async def _local_contents(place) -> dict: - """사업장 지역의 노출 가능한 지역 정보. {"region_code", "contents":[...]} + """사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]} + + 두 캐시를 합친다 — + area_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목 + place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김·종료된 것 제외 ★ 노출 가능 = PUBLISHED + 노출 기간 안. - local_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3). + area_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3). REVIEW 는 아직 사람이 확인하지 않은 외부 API 원문이고, ENDED 는 내린 것이다. 둘 중 하나라도 사이트로 새면 '미검증 값 노출 금지'가 깨진다 — fact 를 VERIFIED/CORRECTED 로, 사진을 APPROVED 로 거르는 것과 같은 규칙을 같은 이유로 적용한다. @@ -192,48 +197,97 @@ async def _local_contents(place) -> dict: region_code = region_key( str(getattr(place, "road_address", None) or getattr(place, "address", None) or "") ) or "" - if not region_code: - return {"region_code": None, "contents": []} - now = datetime.now(timezone.utc) - query = ( - select(local_contents) - .where( - local_contents.region_code == region_code, - local_contents.deleted == False, # noqa: E712 - local_contents.status == LocalContentStatus.PUBLISHED.value, - or_(local_contents.display_start_at.is_(None), local_contents.display_start_at <= now), - or_(local_contents.display_end_at.is_(None), local_contents.display_end_at > now), - ) - .order_by(local_contents.content_type.asc(), local_contents.collected_at.desc()) - ) - err, rows = await DB_SESSION_MNG.execute_lambda( - local_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query) - ) - if err != ErrorType.SUCCESS: - # ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다. - # 빈 채로 나가면 렌더러가 그 섹션을 아예 그리지 않는다. - LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}") - return {"region_code": region_code, "contents": []} + contents: list[dict] = [] + seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다 - seen: dict[int, int] = {} - contents = [] - for row in rows or []: + # ── 지역 캐시(area_contents): 날씨 + 운영자가 수기로 발행한 항목 ── + if region_code: + query = ( + select(area_contents) + .where( + area_contents.region_code == region_code, + area_contents.deleted == False, # noqa: E712 + area_contents.status == LocalContentStatus.PUBLISHED.value, + or_(area_contents.display_start_at.is_(None), area_contents.display_start_at <= now), + or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now), + ) + .order_by(area_contents.content_type.asc(), area_contents.collected_at.desc()) + ) + err, rows = await DB_SESSION_MNG.execute_lambda( + area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query) + ) + if err != ErrorType.SUCCESS: + # ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다. + LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}") + rows = [] + contents += _local_rows(rows or [], seen) + + # ── 업장 반경 캐시(place_area_refs): 맛집·관광지·축제·여행코스 ── + # ★ 정렬은 **사진 있는 것 우선 → 가까운 순**(2026-09-07 결정). 종류별 상한 안에 들려면 + # 사진 없는 가까운 곳보다 사진 있는 조금 먼 곳이 이긴다 — 화면이 카드라 사진이 없으면 자리가 빈다. + place_id = getattr(place, "place_id", None) + if place_id is not None: + # ★ 실체(area_contents)와 관계(place_area_refs)를 조인한다 — 2026-09-09 에 값과 관계를 + # 갈랐다. 사진 유무는 body.firstimage 로 판단한다(같은 사실을 컬럼으로 또 두지 않는다). + nearby_q = ( + select( + area_contents.content_type, + area_contents.external_id, + area_contents.title, + area_contents.body, + area_contents.display_end_at, + # ★ _local_rows 가 읽는 필드는 빠짐없이 넣는다 — 조인으로 바꾸면서 + # source·collected_at 이 빠져 AttributeError 로 스냅샷이 통째로 죽었다. + area_contents.source, + area_contents.collected_at, + place_area_refs.distance_m, + ) + .join(place_area_refs, place_area_refs.local_content_id == area_contents.local_content_id) + .where( + place_area_refs.place_id == place_id, + place_area_refs.deleted == False, # noqa: E712 + place_area_refs.hidden == False, # noqa: E712 + area_contents.deleted == False, # noqa: E712 + or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now), + ) + .order_by( + area_contents.content_type.asc(), + # 사진 있는 것이 먼저 — 화면이 카드라 사진이 없으면 자리가 빈다. + (area_contents.body["firstimage"].astext != "").desc().nullslast(), + place_area_refs.distance_m.asc(), + ) + ) + err, rows = await DB_SESSION_MNG.execute_lambda( + place_area_refs.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, nearby_q) + ) + if err != ErrorType.SUCCESS: + LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}") + rows = [] + contents += _local_rows(rows or [], seen, source=LocalSource.TOUR_API.value) + + return {"region_code": region_code or None, "contents": contents} + + +def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]: + """행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다. + seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다.""" + out = [] + for row in rows: content_type = int(row.content_type) - # 종류별 상한. 위 order_by 가 collected_at 내림차순이라 최근 수집분이 남는다. taken = seen.get(content_type, 0) if taken >= _LOCAL_MAX_PER_TYPE: continue seen[content_type] = taken + 1 body = row.body if isinstance(row.body, dict) else {} - contents.append({ + out.append({ "content_type": content_type, - "source": row.source, + "source": source if source is not None else row.source, "title": row.title, "body": {k: v for k, v in body.items() if k not in _LOCAL_BODY_DROP}, "collected_at": _iso(row.collected_at), }) - return {"region_code": region_code, "contents": contents} + return out def _iso(value) -> str | None: @@ -250,7 +304,7 @@ def _iso(value) -> str | None: async def _select(query) -> list: err, rows = await DB_SESSION_MNG.execute_lambda( - facts.DBType(), + place_facts.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query), ) diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py index 0b1e534..766c219 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -10,7 +10,7 @@ import uuid from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import media, places +from common.database.model.models import place_photos, places from common.enums import DBWRType, ErrorType, MediaStatus, PlaceCategory from common.logger import LOG from common.utils.gtime import GTime @@ -47,7 +47,7 @@ async def run_vision(job: dict) -> dict: # force 가 아니면 아직 분석 안 된 사진만 — 같은 사진을 다시 태우면 요금만 나간다. list_err, rows = await DB_SESSION_MNG.execute_lambda( - media.DBType(), + place_photos.DBType(), DBWRType.DB_READ.value, lambda s: _media_crud.list_media(s, uuid.UUID(place_id), unlabeled_only=not force), ) @@ -58,7 +58,7 @@ async def run_vision(job: dict) -> dict: # 객실·메뉴 이름을 힌트로 준다 — 라벨이 units 와 같은 어휘로 나오면 매칭이 쉬워진다. unit_err, unit_rows = await DB_SESSION_MNG.execute_lambda( - media.DBType(), + place_photos.DBType(), DBWRType.DB_READ.value, lambda s: _place_crud.list_units(s, uuid.UUID(place_id)), ) @@ -101,7 +101,7 @@ async def run_vision(job: dict) -> dict: approved = not result.needs_review status = MediaStatus.APPROVED.value if approved else MediaStatus.PENDING_REVIEW.value run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim( - media.DBType(), + place_photos.DBType(), lambda s, r=result, st=status: _media_crud.apply_vision( s, by_key[r.origin_url].media_id, r.label, r.alt_text, r.confidence, st, now ), diff --git a/solution/backend/tests/test_faq_api.py b/solution/backend/tests/test_faq_api.py index 6adda5c..50b0ad0 100644 --- a/solution/backend/tests/test_faq_api.py +++ b/solution/backend/tests/test_faq_api.py @@ -112,7 +112,7 @@ async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client """검증: 사장님이 문구를 고쳐 승인(CORRECTED)한 뒤 COPY 잡이 재생성을 돌린다. 기대결과: 고친 문구가 그대로 남는다 — ★ 자동 생성이 사람의 판단을 덮어쓰지 않는다.""" from common.database.db_session_manager import DB_SESSION_MNG - from common.database.model.models import faqs + from common.database.model.models import place_faqs from common.utils.gtime import GTime from crud.faq_crud import FaqCRUD @@ -129,7 +129,7 @@ async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client # 재생성이 미확인 FAQ 를 내리는 단계(COPY 잡이 실제로 부르는 그 함수). await DB_SESSION_MNG.execute_lambda_claim( - faqs.DBType(), + place_faqs.DBType(), lambda s: FaqCRUD().expire_generated(s, uuid.UUID(pid), GTime.UTC()), ) after = await _list(client, h, pid, publishable_only=True) diff --git a/solution/frontend/public/robots.txt b/solution/frontend/public/robots.txt new file mode 100644 index 0000000..d7cd58c --- /dev/null +++ b/solution/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /admin/ \ No newline at end of file diff --git a/solution/frontend/public/sitemap.xml b/solution/frontend/public/sitemap.xml new file mode 100644 index 0000000..120fbd0 --- /dev/null +++ b/solution/frontend/public/sitemap.xml @@ -0,0 +1,10 @@ + + + + + https://web4ai.o2osolution.ai/ + 2026-08-12T00:00:00+09:00 + 1.0 + + + \ No newline at end of file diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index f686845..ce60a5a 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -38,6 +38,15 @@ const LOOK = { headingTracking: '0em', headingWeight: '400', sectionSpace: '4rem', + /** + * 갱지 결 — 가로 3px · 세로 4px 간격의 아주 옅은 줄 두 겹. + * + * ★ 이 칸이 비어 있어서 '옛 항구'를 골라도 면이 매끈했다. 타입(`TemplateLook.texture`)에도 + * 있고 발행본이 심는 코드(`seo/head.ts`)도 있는데 **주는 쪽만 없었다** — 색과 서체는 + * 레트로인데 종이가 아니라, 인쇄물이 아니라 '갈색 웹페이지'로 보였다. + */ + texture: + 'repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)', }, } as const; @@ -92,10 +101,11 @@ function templatesFor( toneLabel: '레트로 감성', description: retro.description, colors: { + // 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2. primary: '#1b1a15', secondary: '#4c4739', bg: '#e4dac0', - card: '#f2ebd9', + card: '#efe7d3', text: '#1b1a15', // 레트로의 정체성이 이 주(朱) 잉크다 — 업종 accent 로 갈아끼우지 않는다. accent: '#bf2f1b', @@ -103,7 +113,16 @@ function templatesFor( fontStyle: '옛 간판체', look: LOOK.retro, // 이 템플릿이 팔려는 게 바로 이 아이템들이다. - defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'], + /** + * 이 템플릿이 데려오는 아이템. + * + * ★ 예전 값('course' · 'schedule')은 **렌더러에 없는 타입**이었다. 시안이 그 둘을 + * itinerary · event 로 갈아치웠는데 여기만 남아서, '옛 항구'를 골라도 아이템이 + * 하나도 안 붙었다(실측 2026-09-09). + * ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다 — '지역 이야기'(story) 탭이 데이터가 + * 있는 것만 골라 그린다. 따로 붙이면 탭 밖에 한 번 더 선다. + */ + defaultSectionTypes: ['event', 'video', 'festival', 'itinerary', 'story'], }, ]; } @@ -121,17 +140,23 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'instagram', name: '인스타그램', checked: true }, ], sections: [ + // ★ 백엔드 기본 표(`site_payload._DEFAULT_THEME`)와 id·순서·이름·잠금이 1:1 이어야 한다. + // 어긋나면 에디터에서 본 섹션이 발행본에서 통째로 사라진다. { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '상단 메인 비주얼과 대표 문구' }, { id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '스테이의 철학과 공간 스토리' }, { id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' }, + { id: 'event', type: 'event', name: '소식', isLocked: false, isEnabled: true, description: '지금 하는 행사 · 공지' }, { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' }, - { id: 'rules', type: 'rules', name: '이용 규정', isLocked: false, isEnabled: true, description: '환불 규정, 입실 수칙 및 에티켓' }, { id: 'booking', type: 'booking', name: '예약 안내', isLocked: false, isEnabled: true, description: '요금 · 예약 창구 안내' }, + { id: 'video', type: 'video', name: '영상', isLocked: false, isEnabled: true, description: '유튜브 주소 하나면 됩니다' }, { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' }, { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' }, - { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' }, + { id: 'festival', type: 'festival', name: '계절별 축제', isLocked: false, isEnabled: true, description: '주변에서 열리는 축제 — 계절로 묶어 보여줍니다' }, { id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' }, + { id: 'itinerary', type: 'itinerary', name: '추천 일정', isLocked: false, isEnabled: true, description: '숙소에서 출발하는 하루 코스' }, + { id: 'story', type: 'story', name: '지역 이야기', isLocked: false, isEnabled: true, description: '가요·인물·연표·엽서·퀴즈를 탭으로' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' }, + { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' }, ], templates: templatesFor('stay', '#2563eb', { name: '옛 항구', diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index a41eee3..89fc38f 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -84,6 +84,9 @@ export function CanvasView() { '--tpl-heading-weight': look.headingWeight, '--tpl-section-space': look.sectionSpace, '--tpl-border-width': look.borderWidth, + // 종이 결. 색·서체만으로는 '인쇄물'이 안 된다 — 갱지 면이 있어야 레트로가 레트로로 읽힌다. + // 없는 템플릿에서는 undefined 라 변수 자체가 안 걸리고, `.paper` 가 폴백(none)으로 떨어진다. + '--tpl-texture': look.texture, // ★ Tailwind v4 의 테마 변수를 캔버스 안에서만 덮는다. 이러면 변이 파일 40여 개에 흩어진 // rounded-* · shadow-* 를 하나도 안 고치고 전부 템플릿을 따르게 된다. diff --git a/solution/frontend/src/features/builder/ItemFormEditor.tsx b/solution/frontend/src/features/builder/ItemFormEditor.tsx new file mode 100644 index 0000000..1c49fea --- /dev/null +++ b/solution/frontend/src/features/builder/ItemFormEditor.tsx @@ -0,0 +1,275 @@ +/** + * 붙여넣기 아이템의 **직접 입력** 폼. + * + * ★ 왜 만들었나 + * 입구가 JSON 붙여넣기 하나뿐이었다. 한 글자만 고치려 해도 사장님이 중괄호와 쉼표를 + * 헤집어야 했고, 쉼표 하나 잘못 지우면 섹션이 통째로 사라졌다. ChatGPT 를 안 쓰는 + * 사장님은 아예 채울 수가 없었다. + * + * ★ 폼과 JSON 은 한 값의 두 얼굴이다 + * 진실은 `section.data` **문자열 하나**뿐이고 이 폼은 그걸 비춘다. 그래서 + * JSON 을 붙여넣으면 폼이 따라 바뀌고, 폼을 고치면 JSON 이 다시 쓰인다 — + * 어느 쪽이 최신인지 물을 일이 없다. 폼 상태를 따로 들고 있으면 그 순간 + * "화면은 새 값, 저장은 옛 값"이 생긴다. + * + * ★ 깨진 JSON 은 폼으로 못 편다. 그때는 폼을 감추고 오류만 남긴다 — + * 반쯤 읽힌 값으로 폼을 그리면 사장님이 쓴 걸 덮어쓴다. + */ +import {Plus, Trash2} from 'lucide-react'; +import {parseSectionData} from '@o2o/shared'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {cn} from '@/lib/utils'; +import type {ItemField, SectionDataSpec} from './canvas/dataSpec'; + +type Row = Record; + +/** 'source.name' 처럼 점이 있는 키를 읽는다. */ +function readAt(row: Row, key: string): unknown { + return key.split('.').reduce((node, part) => { + if (node === null || typeof node !== 'object') return undefined; + return (node as Row)[part]; + }, row); +} + +/** 값을 쓴다. 빈 값이면 **키를 지운다** — 빈 문자열을 남기면 "확인 안 된 값"이 발행본에 나간다. */ +function writeAt(row: Row, key: string, value: unknown): Row { + const [head, ...rest] = key.split('.'); + const next = {...row}; + if (rest.length === 0) { + if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { + delete next[head]; + } else { + next[head] = value; + } + return next; + } + const child = (typeof next[head] === 'object' && next[head] !== null ? next[head] : {}) as Row; + const written = writeAt(child, rest.join('.'), value); + if (Object.keys(written).length === 0) delete next[head]; + else next[head] = written; + return next; +} + +function toInput(field: ItemField, value: unknown): string { + if (value === undefined || value === null) return ''; + if (field.type === 'tags') return Array.isArray(value) ? value.join(', ') : String(value); + if (field.key === 'turning') return value === true ? '예' : value === false ? '아니오' : ''; + return String(value); +} + +function fromInput(field: ItemField, text: string): unknown { + const trimmed = text.trim(); + if (trimmed === '') return undefined; + if (field.type === 'tags') return trimmed.split(',').map((t) => t.trim()).filter(Boolean); + if (field.type === 'number') { + const n = Number(trimmed.replace(/[^0-9.-]/g, '')); + return Number.isFinite(n) ? n : undefined; + } + if (field.key === 'turning') return trimmed === '예' ? true : trimmed === '아니오' ? false : undefined; + return text; +} + +function Field({ + field, + row, + onChange, +}: { + field: ItemField; + row: Row; + onChange: (key: string, value: unknown) => void; +}) { + const value = toInput(field, readAt(row, field.key)); + const set = (text: string) => onChange(field.key, fromInput(field, text)); + + return ( +