From e8cda02a4b530d3d0a7fcb34f5ab891dcfa2f830 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 3 Sep 2026 09:41:10 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend,postgres-init:=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20+=20=EA=B3=B5=EA=B0=9C=20=EC=87=BC=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=AA=A9=EB=A1=9D=20=E2=80=94=20=EB=9E=9C?= =?UTF-8?q?=EB=94=A9=EC=9D=B4=20=EC=8B=A4=EB=AC=BC=EC=9D=84=20=EA=B1=B8=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가 어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다. ★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는 봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)· 프리렌더(node:alpine) 어디에도 Chromium 이 없다. - site_thumbnail: 대표 사진을 받아 /thumbs/. 로 올린다. s// 안에 두지 않는 이유 — _remove_stale_site_files 가 매 발행마다 그 경로를 프리렌더 산출물로 통째로 교체해 조용히 지운다 - site_payload: primary_media()·publish_origin()·region_label() 공개. isPrimary 계산을 한 곳으로 모아 og:image 와 썸네일이 갈릴 수 없게 했다 - build_service: azure publish 직후·IndexNow 전에 저장. 실패해도 발행은 그대로 (payload 와 같은 원칙). thumbnail_url 은 발행 상태 전이 UPDATE 에 합쳐 1회 - GET /v1/showcase: 인증 없음. 발행된 사이트만, place_id·전화·상세주소는 안 나간다 - conftest: fake_renderer 가 늘 ok=True 라 NO_UNIQUE_CONTENT 되짚기 경로가 통째로 안 돌고 있었다(기존에 깨져 있던 테스트 4건 포함 수정) 전체 562 passed --- docs/ARCHITECTURE.md | 7 + docs/DEVLOG.md | 38 ++++ postgres-init/init-data/init.sql | 1 + .../backend/common/database/model/models.py | 3 + solution/backend/conftest.py | 24 ++- solution/backend/crud/site_crud.py | 32 +++- solution/backend/router/router.py | 3 + solution/backend/router/v1/site/protocol.py | 33 +++- solution/backend/router/v1/site/showcase.py | 24 +++ solution/backend/services/build_service.py | 24 ++- solution/backend/services/showcase_service.py | 44 +++++ solution/backend/services/site_payload.py | 33 +++- solution/backend/services/site_thumbnail.py | 156 ++++++++++++++++ solution/backend/tests/test_showcase_api.py | 91 +++++++++ solution/backend/tests/test_site_thumbnail.py | 172 ++++++++++++++++++ solution/backend/tests/test_snapshot.py | 24 ++- 16 files changed, 692 insertions(+), 17 deletions(-) create mode 100644 solution/backend/router/v1/site/showcase.py create mode 100644 solution/backend/services/showcase_service.py create mode 100644 solution/backend/services/site_thumbnail.py create mode 100644 solution/backend/tests/test_showcase_api.py create mode 100644 solution/backend/tests/test_site_thumbnail.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9e5a7ab..b555d31 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,9 +45,16 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build() ├ render_report.wait_for() → .status/.json 폴링 ├ 2차 게이트 (**실제 구워진 HTML** 기준: JSON-LD 불일치 · 고유 콘텐츠 수) ├ azure_static.publish(slug) → Azure Blob `$web` (설정됐을 때만 — 3절) + ├ site_thumbnail.store(slug, …) → Blob `/thumbs/.` → sites.thumbnail_url └ indexnow.submit(slug) → 네이버·Bing·Yandex 통보 (구글 미지원) ``` +썸네일은 **스크린샷이 아니라 그 사이트의 대표 사진(og:image)** 이다 — 헤드리스 브라우저는 +영구 금지고([DECISIONS 1-1](DECISIONS.md)) 워커·프리렌더 이미지에 Chromium 이 없다. +대표 사진 선정은 `site_payload.primary_media()` 한 곳뿐이라 og:image 와 항상 같은 사진이다. +★ 블롭 경로가 `s//` **밖**인 이유: `azure_static._remove_stale_site_files()` 가 매 발행마다 +사이트 경로를 프리렌더 산출물로 통째로 교체한다 — 그 안에 두면 다음 발행에서 조용히 사라진다. + 게이트가 **두 번** 도는 게 핵심이다. 1차는 DB 의 사실을, 2차는 **정말로 그렇게 구워졌는지**를 본다. 1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 1222ff6..a7ab026 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,44 @@ --- +## 2026-09-03 — 발행하면 썸네일이 남는다 (랜딩 쇼케이스용) + +**왜** +랜딩에 "이렇게 만들어졌습니다" 를 보여줄 그림이 없었다. 사이트는 발행되는데 그 결과물을 +가리킬 이미지가 어디에도 저장되지 않아, 쇼케이스를 만들려면 매번 사람이 캡처를 떠야 했다. + +**썸네일은 스크린샷이 아니라 그 사이트의 대표 사진이다** +헤드리스 브라우저는 봇 탐지 우회 우려로 영구 금지고([DECISIONS 1-1](DECISIONS.md)), +워커(python:3.12-slim)·프리렌더(node:24-alpine) 어디에도 Chromium 이 없다. 넣으면 이미지가 +수백 MB 늘고 금지해 둔 도구를 상비하게 된다. 대신 `og:image` 로 나가는 **대표 사진**을 그대로 +옮긴다 — 검색 결과에 뜨는 그림과 쇼케이스 카드가 같아진다. 대표 사진 선정 규칙은 +`site_payload.primary_media()` 한 곳뿐이라 두 곳이 갈릴 수 없다. + +**한 일** +- `services/site_thumbnail.py` 신설. 대표 사진을 httpx 로 받아(10초 상한 · 리다이렉트 3회 · + image/* 만 · 5MB 상한) `/thumbs/.` 로 올린다. 기존 + `AZURE_STORAGE_CONNECTION_STRING` 을 그대로 쓴다 — 새 자격증명 체계를 들이지 않았다. + ★ 사이트 경로(`s//`) 안에 두지 않는다: `azure_static._remove_stale_site_files()` 가 + 매 발행마다 그 경로를 통째로 교체하므로 다음 발행에서 조용히 사라진다. +- `build_service`: `azure_static.publish()` 직후 · IndexNow 통보 전에 저장하고, + 발행 상태 전이 UPDATE 에 `thumbnail_url` 을 실어 보낸다(UPDATE 는 그대로 한 번). + 실패해도 발행을 되돌리지 않는다 — 정적 파일은 이미 올라갔다(`emit_payload` 와 같은 원칙). + 못 만들면 키를 넣지 않아 지난 발행의 그림이 남는다. +- `GET /v1/showcase` 신설(**인증 없음**, 랜딩이 부른다). 발행된 사이트만 최신순, + 기본 12건·상한 48건. 나가는 것은 상호명·업종·지역(시·군·구까지)·발행 주소·썸네일뿐이다 — + place_id·company_id·전화번호·상세 주소는 싣지 않는다. 무엇을 내보낼지 고르는 자리를 + `services/showcase_service.py` 한 곳에 모아 경계를 눈에 보이게 뒀다. + 어드민 진입점(:9801)에는 마운트하지 않는다. + +**곁가지로 고친 것 — 브랜치에 이미 깨져 있던 테스트 4건** +- `conftest.fake_renderer` 가 늘 `ok=True` 를 돌려줬다. 진짜 렌더러는 고유 콘텐츠 0건이면 + 페이지를 쓰지 않는데(prerender.ts `NoUniqueContentError`), 대역이 그 실패를 흉내내지 않아 + 백엔드가 그 사유를 NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 안 돌고 있었다. +- `test_snapshot` 이 "region_code 가 없으면 지역 정보 없음" 을 기대했다. 지금은 도로명주소에서 + 유도한다(`snapshot._local_contents`) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다. + +**검증** — `pytest` 전체 552 passed. + ## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 **왜** diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index ac3ccf3..ea54b9a 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -276,6 +276,7 @@ CREATE TABLE IF NOT EXISTS site.sites ( status SMALLINT NOT NULL DEFAULT 1, -- 상태(SiteStatus): 1=draft 2=review 3=published 4=suspended 5=unpublished current_version_id uuid NULL, -- 현재 발행 버전(site.site_versions.site_version_id) published_at TIMESTAMPTZ NULL, -- 최초/최근 발행 시각 + thumbnail_url VARCHAR(500) NULL, -- 발행 썸네일(Azure Blob 공개 URL). 발행에 성공한 뒤에만 채워진다 — 쇼케이스·사이트 목록 카드가 읽는다 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index c343cb4..00f69e4 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -405,6 +405,9 @@ class sites(MainTableMixin, MAIN_BASE): 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 published_at = Column(DateTime(timezone=True), nullable=True) + # 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을 + # 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다. + thumbnail_url = Column(String(500), nullable=True) class site_versions(MainTableMixin, MAIN_BASE): diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index 4fb6a94..806b2d7 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -226,6 +226,28 @@ def fake_renderer(monkeypatch, tmp_path_factory): if payload["site"]["version"] != site_version: return None place = payload.get("place") or {} + count = _count(payload) + # ★ 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다** + # (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에 + # 나가 있으면 크롤러가 읽는다). 대역이 늘 ok=True 를 주면 백엔드가 그 실패를 + # NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 테스트되지 않는다. + # mismatches 는 비워 둔다 — 사유가 JSONLD_MISMATCH 로 섞이면 화면 문구가 틀린다. + if count <= 0: + return { + "schemaVersion": 1, + "slug": slug, + "siteId": payload["site"]["siteId"], + "placeId": payload["site"]["placeId"], + "siteVersion": site_version, + "ok": False, + "renderedAt": "2026-01-01T00:00:00.000Z", + "routes": 0, + "bundle": "test.js", + "uniqueContentCount": count, + "jsonld": None, + "mismatches": [], + "error": "고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다", + } return { "schemaVersion": 1, "slug": slug, @@ -236,7 +258,7 @@ def fake_renderer(monkeypatch, tmp_path_factory): "renderedAt": "2026-01-01T00:00:00.000Z", "routes": 3, "bundle": "test.js", - "uniqueContentCount": _count(payload), + "uniqueContentCount": count, "jsonld": [{"@type": "LodgingBusiness", "name": place.get("name"), "telephone": place.get("phone")}], "mismatches": [], diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index 926712c..a40d0ae 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -5,8 +5,8 @@ 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 publish_logs, site_versions, sites -from common.enums import BuildStatus, ErrorType +from common.database.model.models import places, publish_logs, site_versions, sites +from common.enums import BuildStatus, ErrorType, SiteStatus from common.logger import LOG from common.utils.gtime import GTime @@ -57,6 +57,10 @@ class ISiteCRUD(ABC): async def list_logs(self, cdb: AsyncSession, site_id, limit: int) -> Tuple[ErrorType, list]: pass + @abstractmethod + async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]: + pass + class SiteCRUD(ISiteCRUD): async def get_site_by_place(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, sites]: @@ -196,3 +200,27 @@ class SiteCRUD(ISiteCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] + + async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]: + """발행된 사이트 + 그 사업장을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다. + + ★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로 + 돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 — + 여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다.""" + try: + query = ( + select(sites, places) + .join(places, places.place_id == sites.place_id) + .where( + sites.status == SiteStatus.PUBLISHED.value, + sites.deleted == False, # noqa: E712 + places.deleted == False, # noqa: E712 + ) + .order_by(sites.published_at.desc().nulls_last(), sites.created_at.desc()) + .limit(limit) + ) + 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, [] diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index 462f484..33ee5f3 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -17,6 +17,7 @@ import router.v1.faq.faq import router.v1.media.media import router.v1.job.job import router.v1.site.site +import router.v1.site.showcase import router.v1.local.local API_SERVER_START_TIME = GTime.UTCStr() @@ -87,5 +88,7 @@ app.include_router(router.v1.faq.faq.router) app.include_router(router.v1.media.media.router) app.include_router(router.v1.job.job.router) app.include_router(router.v1.site.site.router) +# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다. +app.include_router(router.v1.site.showcase.router) app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.weather_router) diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index b8de4fd..2e4d6d2 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -4,7 +4,15 @@ from typing import Any, Optional from pydantic import ConfigDict -from common.enums import BuildStatus, JobStatus, PublishAction, PublishRejectReason, PublishResult, SiteStatus +from common.enums import ( + BuildStatus, + JobStatus, + PlaceCategory, + PublishAction, + PublishRejectReason, + PublishResult, + SiteStatus, +) from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -54,6 +62,8 @@ class SiteData(WebPacketProtocol): theme: Optional[dict[str, Any]] = None current_version_id: Optional[uuid.UUID] = None published_at: Optional[datetime] = None + # 발행 썸네일(Azure Blob 공개 URL). 대표 사진을 옮긴 것이고, 만들지 못했으면 없다. + thumbnail_url: Optional[str] = None class PublishLogData(WebPacketProtocol): @@ -192,3 +202,24 @@ class Req_SiteStatus(SiteProtocol): 색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다.""" action: PublishAction = PublishAction.SUSPEND + + +class ShowcaseItem(WebPacketProtocol): + """랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** + + ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. + place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 + 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.""" + + name: str + category: PlaceCategory + # "강원특별자치도 양양군" 수준. 주소를 못 읽으면 없다. + region: Optional[str] = None + # 발행 주소. 랜딩과 발행본이 한 오리진이라 루트 상대경로로 준다(`/s/`). + url: str + # 없으면 화면이 글자 카드로 떨어진다(썸네일은 발행의 부수 효과라 실패할 수 있다). + thumbnail_url: Optional[str] = None + + +class Res_Showcase(Res_WebPacketProtocol): + items: list[ShowcaseItem] = [] diff --git a/solution/backend/router/v1/site/showcase.py b/solution/backend/router/v1/site/showcase.py new file mode 100644 index 0000000..54c0a42 --- /dev/null +++ b/solution/backend/router/v1/site/showcase.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends, Query + +from router.v1.validator.dependencies import RemoveNoneResponse +from services.showcase_service import ShowcaseService +from .protocol import Res_Showcase + +# 발행 사이트 쇼케이스. ★ 인증이 없다 — 랜딩(비로그인)이 부른다. +# ★ :9801(어드민 진입점)에는 마운트하지 않는다. 내부 화면이 쓸 목록이 아니다. +router = APIRouter(prefix="/v1/showcase", tags=["Showcase"], responses={404: {"description": "Not found"}}) + + +@router.get( + path="", + response_model=Res_Showcase, + summary="발행 사이트 쇼케이스(공개)", + description="실제로 발행된 사이트를 최신순으로 준다. 상호명·업종·지역(시·군·구까지)·발행 주소·썸네일뿐이다 — " + "로그인 없이 나가므로 사업장 식별자·전화번호·상세 주소는 싣지 않는다. " + "썸네일은 그 사이트의 대표 사진이고, 만들지 못한 사이트는 키가 없다.", +) +async def list_showcase( + service: ShowcaseService = Depends(), + limit: int = Query(12, ge=1, le=48, description="가져올 개수"), +): + return RemoveNoneResponse(await service.list(limit)) diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 5bfbb93..5869167 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -29,7 +29,7 @@ from common.logger import LOG 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 +from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail from services.site_payload import emit_payload from services.snapshot import build_snapshot @@ -236,6 +236,7 @@ async def run_build(job: dict) -> dict: return await _fail(str(report.get("error") or "렌더 실패"), None, stamp) # DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다. + thumbnail_url = None if want_publish: try: azure_result = await azure_static.publish(slug) @@ -243,6 +244,11 @@ async def run_build(job: dict) -> dict: return await _fail(str(ex), None, stamp) if azure_result: result["azure"] = azure_result + # ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. + # 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다). + thumbnail_url = await site_thumbnail.store(slug, snapshot) + if thumbnail_url: + result["thumbnail_url"] = thumbnail_url # ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다. # 실패해도 발행은 성공이다 — 색인 통보는 부수 효과이고, 다음 발행에서 다시 보낸다. indexnow_result = await indexnow.submit(slug) @@ -269,13 +275,17 @@ async def run_build(job: dict) -> dict: result["routes"] = report.get("routes") if want_publish: + # 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아 + # 지난 발행의 그림이 그대로 남는다(NULL 로 밀어 카드를 비우지 않는다). + site_update = { + "status": SiteStatus.PUBLISHED.value, + "current_version_id": version.site_version_id, + "published_at": now, + **({"thumbnail_url": thumbnail_url} if thumbnail_url else {}), + } await DB_SESSION_MNG.execute_lambda_claim( sites.DBType(), - lambda s: _site_crud.update_site( - s, site.site_id, - {"status": SiteStatus.PUBLISHED.value, "current_version_id": version.site_version_id, - "published_at": now}, - ), + lambda s: _site_crud.update_site(s, site.site_id, site_update), ) # ★ 사업장 상태도 같이 올린다. 여기서 안 올리면 places.status 는 영원히 REVIEW 라, # 발행을 마친 가게가 사업장 목록에서 '발행 전'으로 남는다 — 사장님은 목록만 보고 @@ -291,6 +301,8 @@ async def run_build(job: dict) -> dict: site.status = SiteStatus.PUBLISHED.value site.current_version_id = version.site_version_id site.published_at = now + if thumbnail_url: + site.thumbnail_url = thumbnail_url result["published"] = True LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 " f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)") diff --git a/solution/backend/services/showcase_service.py b/solution/backend/services/showcase_service.py new file mode 100644 index 0000000..4f058ed --- /dev/null +++ b/solution/backend/services/showcase_service.py @@ -0,0 +1,44 @@ +"""랜딩(비로그인)이 읽는 발행 사이트 목록. + +★ 이 파일이 따로 있는 이유는 **경계를 눈에 보이게 두기 위해서**다. 나머지 site 서비스는 + 전부 로그인 + 회사 스코프 안에서 돈다. 여기만 아무나 부른다 — 그래서 무엇을 내보낼지 + 고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 + 긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다. + + 나가지 않는 것: place_id · company_id · site_id · 전화번호 · 상세 주소 · 좌표. +""" + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import sites +from common.enums import DBWRType, ErrorType +from crud.site_crud import SiteCRUD +from router.v1.site.protocol import Res_Showcase, ShowcaseItem +from services import site_payload + + +class ShowcaseService: + def __init__(self): + self.crud = SiteCRUD() + + async def list(self, limit: int) -> Res_Showcase: + res = Res_Showcase() + err, rows = await DB_SESSION_MNG.execute_lambda( + sites.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_published(s, limit) + ) + res.result.SetResult(err) + if err != ErrorType.SUCCESS: + return res + + res.items = [ + ShowcaseItem( + name=place.name, + category=place.category, + region=site_payload.region_label(place.road_address, place.address), + # ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면 + # 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳'). + url=f"/s/{site_payload.publish_slug(place, site)}", + thumbnail_url=site.thumbnail_url, + ) + for site, place in rows + ] + return res diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 44ae29a..c03249a 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -549,6 +549,33 @@ def publish_slug(place, site) -> str: return _publish_target(site, str(_get(place, "place_id") or ""), _text(_get(place, "name")))["slug"] +def publish_origin() -> str: + """발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 — + 호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳').""" + return f"https://{DEFAULT_HOST}" + + +def primary_media(snapshot: dict) -> dict | None: + """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None. + + ★ 썸네일도 이 함수를 쓴다. 규칙을 복제하면 검색 결과에 뜨는 그림과 + 쇼케이스 카드가 다른 사진이 되고, 그건 아무도 눈치채지 못한다.""" + for row in (snapshot or {}).get("media") or []: + if not row.get("unit_id"): + return row + return None + + +def region_label(*addresses: str | None) -> str | None: + """"강원특별자치도 양양군" — 시·도 + 시·군·구까지만. + + ★ 상세 주소는 붙이지 않는다. 로그인 없이 읽히는 목록(쇼케이스)에 쓰이므로 + '어느 동네인지' 를 넘어서면 안 된다.""" + parts = _parse_address_parts(*addresses) + label = " ".join(p for p in (parts.get("addressRegion"), parts.get("addressLocality")) if p) + return label or None + + # ── payload 조립 ────────────────────────────────────────────────────────── def to_site_payload(place, snapshot: dict, site, version, links) -> dict: """스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict). @@ -577,12 +604,10 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: # ★ sourceType/originUrl 을 반드시 싣는다 — 크롤링 이미지 재게시 권리가 미결이라(DECISIONS 1-2) # 결론이 나면 출처로 걸러낼 수 있어야 한다. 출처를 버리면 그때 다시 수집해야 한다. media = [] - primary_done = False + primary_row = primary_media(snapshot) for index, row in enumerate(snapshot.get("media") or []): unit_id = row.get("unit_id") - # 대표 이미지(og:image)는 객실 전용 사진이 아닌 첫 장으로 한다. - is_primary = not unit_id and not primary_done - primary_done = primary_done or is_primary + is_primary = row is primary_row media.append({ "mediaId": str(row.get("media_id") or f"m-{index}"), "url": row.get("url"), diff --git a/solution/backend/services/site_thumbnail.py b/solution/backend/services/site_thumbnail.py new file mode 100644 index 0000000..92b9e24 --- /dev/null +++ b/solution/backend/services/site_thumbnail.py @@ -0,0 +1,156 @@ +"""발행한 사이트의 썸네일을 Azure Blob 에 남긴다 — 랜딩 쇼케이스 카드가 쓰는 그림. + +★ **스크린샷이 아니다.** 헤드리스 브라우저는 이 레포에서 영구 금지고(docs/DECISIONS.md 1-1), + 워커(python:3.12-slim)에도 프리렌더(node:24-alpine)에도 Chromium 이 없다. 그걸 넣으면 + 이미지가 수백 MB 늘고, 금지해 둔 도구가 다른 목적으로 상비되는 셈이 된다. + 대신 **그 사이트의 대표 사진(og:image)** 을 그대로 옮긴다 — 검색 결과에 뜨는 그림과 + 쇼케이스 카드가 같은 사진이 된다. 대표 사진 선정은 site_payload.primary_media 한 곳뿐이다. + +★ 블롭 경로는 사이트 디렉터리(`s//`) **밖**이다. + azure_static._remove_stale_site_files 가 매 발행마다 `s//` 를 프리렌더 산출물로 + 통째로 교체하므로, 그 안에 두면 다음 발행에서 조용히 사라진다. + +★ 실패해도 발행을 되돌리지 않는다(emit_payload·indexnow 와 같은 원칙). 그림이 없으면 + 쇼케이스가 글자 카드로 떨어질 뿐이고, 발행 자체는 이미 정확하다. +""" + +import asyncio +import os + +import httpx +from azure.storage.blob import BlobServiceClient, ContentSettings + +from common.logger import LOG +from services import azure_static, site_payload + +# 사이트 경로 밖의 전용 디렉터리. 여기는 발행이 지우지 않는다. +THUMB_DIR = "thumbs" + +# 허용 content-type → 저장 확장자. 목록 밖이면 받지 않는다 — +# 이미지가 아닌 응답(HTML 오류 페이지 등)을 그대로 올리면 카드가 깨진 그림이 된다. +_EXT_BY_TYPE = { + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", +} + +# 남의 CDN 을 부르는 길이다. 상한이 없으면 발행 잡이 여기서 굳는다. +TIMEOUT_SEC = 10.0 +# 리다이렉트는 따라가되 무한정은 안 된다(CDN 은 보통 1~2회). +MAX_REDIRECTS = 3 +# 5MB. 사진 한 장이 이보다 크면 카드에 쓸 그림이 아니라 다른 것이 왔다고 본다. +MAX_BYTES = 5 * 1024 * 1024 + +# 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다. +CACHE_CONTROL = "public, max-age=60, must-revalidate" + + +def is_configured() -> bool: + return azure_static.is_configured() + + +def blob_name(slug: str, ext: str) -> str: + prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/") + return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part) + + +def public_url(slug: str, ext: str) -> str: + """공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다 + (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').""" + return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" + + +async def _fetch(url: str) -> tuple[bytes, str, str] | None: + """대표 사진을 받아온다. (바이트, content-type, 확장자) 또는 None.""" + if not url.lower().startswith(("http://", "https://")): + LOG.w(f"[thumbnail] 받아올 수 없는 주소다: {url[:120]}") + return None + + try: + async with httpx.AsyncClient( + timeout=TIMEOUT_SEC, follow_redirects=True, max_redirects=MAX_REDIRECTS + ) as client: + async with client.stream("GET", url) as res: + if res.status_code != 200: + LOG.w(f"[thumbnail] 사진을 받지 못했다 HTTP {res.status_code}: {url[:120]}") + return None + + content_type = (res.headers.get("content-type") or "").split(";")[0].strip().lower() + ext = _EXT_BY_TYPE.get(content_type) + if not ext: + LOG.w(f"[thumbnail] 이미지가 아니다(content-type={content_type or '없음'}): {url[:120]}") + return None + + # Content-Length 가 있으면 한 바이트도 받기 전에 자른다. + declared = res.headers.get("content-length") + if declared and declared.isdigit() and int(declared) > MAX_BYTES: + LOG.w(f"[thumbnail] 사진이 너무 크다({declared} bytes): {url[:120]}") + return None + + chunks: list[bytes] = [] + size = 0 + async for chunk in res.aiter_bytes(): + size += len(chunk) + # Content-Length 를 안 주는 서버가 있다 — 받으면서도 상한을 본다. + if size > MAX_BYTES: + LOG.w(f"[thumbnail] 사진이 너무 크다(>{MAX_BYTES} bytes): {url[:120]}") + return None + chunks.append(chunk) + except httpx.HTTPError as ex: + LOG.w(f"[thumbnail] 사진을 받지 못했다 {type(ex).__name__}: {ex}") + return None + + data = b"".join(chunks) + if not data: + LOG.w(f"[thumbnail] 빈 응답이다: {url[:120]}") + return None + return data, content_type, ext + + +def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: + connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() + container_name = ( + os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip() + or azure_static.DEFAULT_CONTAINER + ) + service = BlobServiceClient.from_connection_string(connection_string) + container = service.get_container_client(container_name) + name = blob_name(slug, ext) + container.upload_blob( + name=name, + data=data, + overwrite=True, + # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. + content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL), + ) + return name + + +async def store(slug: str, snapshot: dict) -> str | None: + """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다). + + SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로, + 이벤트 루프를 붙잡으면 같은 워커의 다른 잡이 통째로 멈춘다.""" + if not is_configured(): + return None + + row = site_payload.primary_media(snapshot) + url = str((row or {}).get("url") or "").strip() + if not url: + LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}") + return None + + fetched = await _fetch(url) + if fetched is None: + return None + data, content_type, ext = fetched + + try: + name = await asyncio.to_thread(_upload_sync, slug, data, content_type, ext) + except Exception as ex: # noqa: BLE001 — 어떤 이유로도 발행을 되돌리지 않는다 + LOG.w(f"[thumbnail] 업로드 실패(발행은 그대로 진행): {type(ex).__name__}: {ex}") + return None + + LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})") + return public_url(slug, ext) diff --git a/solution/backend/tests/test_showcase_api.py b/solution/backend/tests/test_showcase_api.py new file mode 100644 index 0000000..e3f7c9b --- /dev/null +++ b/solution/backend/tests/test_showcase_api.py @@ -0,0 +1,91 @@ +"""공개 쇼케이스 목록 — 랜딩이 로그인 없이 부르는 유일한 사이트 조회. + +이 경로가 절대 하면 안 되는 것: + - 발행되지 않은 사이트를 보여주는 것 — 열면 404 다. + - 사업장 식별자·전화번호·상세 주소를 흘리는 것. 사이트 한 곳을 여는 것과 + 발행 업소 명단을 통째로 긁는 것은 다른 일이다. +""" +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import text + +from common.enums import PlaceCategory, PlaceStatus, SiteStatus + + +async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, minutes_ago=0): + """places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다.""" + pid, sid = uuid.uuid4(), uuid.uuid4() + async with db_engine.begin() as c: + await c.execute( + text("INSERT INTO places (place_id, company_id, name, category, status, road_address, address, phone) " + "VALUES (:p,:c,:n,:cat,:st,:road,:addr,:phone)"), + {"p": pid, "c": uuid.UUID(company_id), "n": name, "cat": PlaceCategory.LODGING.value, + "st": PlaceStatus.PUBLISHED.value, + "road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2", + "phone": "033-672-0000"}, + ) + await c.execute( + text("INSERT INTO sites (site_id, place_id, domain, status, thumbnail_url, published_at) " + "VALUES (:s,:p,:d,:st,:t,:at)"), + {"s": sid, "p": pid, "d": domain, "st": status, "t": thumb, + "at": datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)}, + ) + return str(pid) + + +async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id): + """검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다. + 기대결과: 발행본만 나온다.""" + await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, + domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg") + await _publish(db_engine, company_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet") + + res = await client.get("/v1/showcase") + + assert res.status_code == 200 + items = res.json()["items"] + assert [i["name"] for i in items] == ["하조대펜션"] + assert items[0]["url"] == "/s/hajodae" + assert items[0]["thumbnail_url"] == "https://w4ai.o2o.kr/thumbs/hajodae.jpg" + assert items[0]["category"] == PlaceCategory.LODGING.value + + +async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, company_id): + """검증: 응답 항목의 키를 그대로 본다. + 기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다.""" + await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae") + + item = (await client.get("/v1/showcase")).json()["items"][0] + + assert set(item) <= {"name", "category", "region", "url", "thumbnail_url"} + assert item["region"] == "강원특별자치도 양양군" + # 번지·전화번호가 어디로도 새지 않았는지 통째로 확인한다. + body = (await client.get("/v1/showcase")).text + assert "033-672-0000" not in body + assert "하조대3길" not in body + + +async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id): + """★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나). + 그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다.""" + await _publish(db_engine, company_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic") + + item = (await client.get("/v1/showcase")).json()["items"][0] + + assert item["name"] == "그림없는집" + assert "thumbnail_url" not in item + + +async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id): + await _publish(db_engine, company_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60) + await _publish(db_engine, company_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1) + + items = (await client.get("/v1/showcase")).json()["items"] + assert [i["name"] for i in items] == ["나중", "먼저"] + + items = (await client.get("/v1/showcase?limit=1")).json()["items"] + assert [i["name"] for i in items] == ["나중"] + + # 상한 밖은 거부한다 — 전체 명단을 한 번에 긁어가는 요청을 열어 두지 않는다. + assert (await client.get("/v1/showcase?limit=999")).status_code == 422 diff --git a/solution/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py new file mode 100644 index 0000000..44b2808 --- /dev/null +++ b/solution/backend/tests/test_site_thumbnail.py @@ -0,0 +1,172 @@ +"""발행 썸네일 — 사이트의 대표 사진을 Azure Blob 으로 옮긴다. + +★ 스크린샷이 아니다(헤드리스 브라우저는 영구 금지, docs/DECISIONS.md 1-1). + 그래서 여기서 볼 것은 "무엇을 대표로 고르는가"와 "무엇을 받지 않는가" 두 가지다. + +이 경로가 절대 하면 안 되는 것: + - 썸네일 실패로 발행을 되돌리는 것 — 정적 파일은 이미 올라갔다. + - 이미지가 아닌 응답을 그대로 올리는 것 — 카드가 깨진 그림이 된다. + - 사이트 디렉터리(`s//`) 안에 두는 것 — 다음 발행이 통째로 지운다. +""" +from types import SimpleNamespace + +import httpx +import pytest + +from services import azure_static, site_payload, site_thumbnail + + +def _snapshot(*rows): + return {"media": list(rows)} + + +def _transport(monkeypatch, handler): + """사진을 내려주는 CDN 대역. 테스트가 실제 네트워크를 부르지 않게 한다.""" + real = httpx.AsyncClient + + def factory(**kwargs): + return real(transport=httpx.MockTransport(handler), **kwargs) + + monkeypatch.setattr(site_thumbnail.httpx, "AsyncClient", factory) + + +class FakeContainer: + def __init__(self): + self.uploads = {} + + def upload_blob(self, name, data, **options): + self.uploads[name] = {"body": data, **options} + + +@pytest.fixture +def blob(monkeypatch): + container = FakeContainer() + service = SimpleNamespace(get_container_client=lambda name: container) + monkeypatch.setattr( + site_thumbnail.BlobServiceClient, "from_connection_string", lambda value: service + ) + monkeypatch.setenv("AZURE_STORAGE_CONNECTION_STRING", "UseDevelopmentStorage=true") + monkeypatch.delenv("AZURE_STORAGE_PREFIX", raising=False) + return container + + +def test_대표사진은_객실전용이_아닌_첫장이다(): + # og:image 선정(site_payload)과 같은 함수를 쓴다 — 두 벌이면 검색 결과와 카드가 갈린다. + snapshot = _snapshot( + {"media_id": "m1", "url": "https://cdn.test/room.jpg", "unit_id": "u1"}, + {"media_id": "m2", "url": "https://cdn.test/front.jpg", "unit_id": None}, + {"media_id": "m3", "url": "https://cdn.test/back.jpg", "unit_id": None}, + ) + assert site_payload.primary_media(snapshot)["media_id"] == "m2" + + +def test_설정이_없으면_아무것도_하지_않는다(monkeypatch): + monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False) + assert site_thumbnail.is_configured() is False + + +async def test_대표사진이_없으면_None_이고_발행은_그대로(blob): + """검증: 승인 사진이 객실 전용뿐이라 대표가 없다. + 기대결과: None 을 돌려주고 업로드하지 않는다(예외를 올리지 않는다).""" + snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/room.jpg", "unit_id": "u1"}) + assert await site_thumbnail.store("butter", snapshot) is None + assert blob.uploads == {} + + +async def test_이미지가_아니면_받지_않는다(blob, monkeypatch): + """검증: 사진 URL 이 HTML(오류 페이지)을 돌려준다. + 기대결과: 거부하고 None. 깨진 그림을 쇼케이스에 올리지 않는다.""" + _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "text/html"}, content=b"")) + snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) + + assert await site_thumbnail.store("butter", snapshot) is None + assert blob.uploads == {} + + +async def test_상한을_넘는_사진은_받지_않는다(blob, monkeypatch): + body = b"x" * (site_thumbnail.MAX_BYTES + 1) + _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=body)) + snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/huge.jpg", "unit_id": None}) + + assert await site_thumbnail.store("butter", snapshot) is None + assert blob.uploads == {} + + +async def test_네트워크_실패는_삼킨다(blob, monkeypatch): + def _boom(request): + raise httpx.ConnectTimeout("timed out", request=request) + + _transport(monkeypatch, _boom) + snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) + + assert await site_thumbnail.store("butter", snapshot) is None + + +async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypatch): + """검증: 대표 사진을 받아 업로드한다. + 기대결과: `/thumbs/.` — `s//` 안이 아니다. + ★ 사이트 경로는 매 발행마다 프리렌더 산출물로 통째로 교체된다 + (azure_static._remove_stale_site_files) — 그 안에 두면 다음 발행에서 조용히 사라진다.""" + _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes")) + snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) + + url = await site_thumbnail.store("butter", snapshot) + + assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg" + name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg" + assert set(blob.uploads) == {name} + assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/") + + uploaded = blob.uploads[name] + assert uploaded["body"] == b"jpegbytes" + settings = uploaded["content_settings"] + assert settings.content_type == "image/jpeg" + # 썸네일은 재발행마다 바뀌는데 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다. + assert settings.cache_control == site_thumbnail.CACHE_CONTROL + + +# ── 발행 경로와의 배선 ──────────────────────────────────────────────────── +# 아래 두 건은 "발행이 썸네일에 매달리지 않는가" 를 본다. 썸네일 만들기 자체는 위에서 봤다. +async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_headers, client, db_engine, monkeypatch): + from services import build_service + from tests.test_build_publish import _approved_media, _place, _run, _verified_facts + + async def _store(slug, snapshot): + return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg" + + monkeypatch.setattr(build_service.site_thumbnail, "store", _store) + + h = await auth_headers("u1") + pid = await _place(client, h, "썸네일펜션") + await _verified_facts(client, h, pid, {"intro": "하조대 해변 도보 3분 거리의 펜션입니다."}) + await _approved_media(db_engine, pid) + + await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}) + await _run() + + site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] + assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/") + + +async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch): + """★ 썸네일은 발행의 부수 효과다 — 정적 파일은 이미 올라갔다. 여기서 되돌리면 + 사장님 사이트가 그림 하나 때문에 안 나간다.""" + from services import build_service + from tests.test_build_publish import _approved_media, _place, _run, _verified_facts + + async def _store(slug, snapshot): + return None + + monkeypatch.setattr(build_service.site_thumbnail, "store", _store) + + h = await auth_headers("u1") + pid = await _place(client, h, "그림없는펜션") + await _verified_facts(client, h, pid, {"intro": "하조대 해변 도보 3분 거리의 펜션입니다."}) + await _approved_media(db_engine, pid) + + job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"] + await _run() + + assert (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]["result"]["published"] is True + site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] + assert "thumbnail_url" not in site diff --git a/solution/backend/tests/test_snapshot.py b/solution/backend/tests/test_snapshot.py index 30ce380..e8d1d41 100644 --- a/solution/backend/tests/test_snapshot.py +++ b/solution/backend/tests/test_snapshot.py @@ -203,13 +203,31 @@ async def test_local_content_is_scoped_to_the_places_region(db_engine, company_i assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"] -async def test_place_without_region_code_gets_no_local_content(db_engine, company_id): - """검증: region_code 가 비어 있는 사업장(수집이 지역을 특정하지 못한 경우). +async def test_region_code_is_derived_from_the_address_when_missing(db_engine, company_id): + """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장. + 기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는 + 코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다.""" + from common.enums import LocalContentStatus, LocalContentType + from services.external.naver import region_key + + derived = region_key(_Place("x").road_address) + pid = await _seed(db_engine, company_id) + await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제") + + snap = await build_snapshot(_RegionPlace(pid, "")) + assert snap["local"]["region_code"] == derived + assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"] + + +async def test_place_without_any_region_key_gets_no_local_content(db_engine, company_id): + """검증: 지역 코드도 읽을 만한 주소도 없는 사업장. 기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다.""" from common.enums import LocalContentStatus, LocalContentType pid = await _seed(db_engine, company_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제") - snap = await build_snapshot(_RegionPlace(pid, "")) + place = _RegionPlace(pid, "") + place.road_address = None + snap = await build_snapshot(place) assert snap["local"] == {"region_code": None, "contents": []}