[feat] solution/backend,postgres-init: 발행 썸네일 저장 + 공개 쇼케이스 목록 — 랜딩이 실물을 걸 자리

랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가
어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다.

★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는
  봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)·
  프리렌더(node:alpine) 어디에도 Chromium 이 없다.

- site_thumbnail: 대표 사진을 받아 <prefix>/thumbs/<slug>.<ext> 로 올린다.
  s/<slug>/ 안에 두지 않는 이유 — _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
This commit is contained in:
Mina Choi 2026-09-03 09:41:10 +09:00
parent 5ef3e5a7de
commit e8cda02a4b
16 changed files with 692 additions and 17 deletions

View File

@ -45,9 +45,16 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
├ render_report.wait_for() → .status/<slug>.json 폴링 ├ render_report.wait_for() → .status/<slug>.json 폴링
├ 2차 게이트 (**실제 구워진 HTML** 기준: JSON-LD 불일치 · 고유 콘텐츠 수) ├ 2차 게이트 (**실제 구워진 HTML** 기준: JSON-LD 불일치 · 고유 콘텐츠 수)
├ azure_static.publish(slug) → Azure Blob `$web` (설정됐을 때만 — 3절) ├ azure_static.publish(slug) → Azure Blob `$web` (설정됐을 때만 — 3절)
├ site_thumbnail.store(slug, …) → Blob `<prefix>/thumbs/<slug>.<ext>` → sites.thumbnail_url
└ indexnow.submit(slug) → 네이버·Bing·Yandex 통보 (구글 미지원) └ indexnow.submit(slug) → 네이버·Bing·Yandex 통보 (구글 미지원)
``` ```
썸네일은 **스크린샷이 아니라 그 사이트의 대표 사진(og:image)** 이다 — 헤드리스 브라우저는
영구 금지고([DECISIONS 1-1](DECISIONS.md)) 워커·프리렌더 이미지에 Chromium 이 없다.
대표 사진 선정은 `site_payload.primary_media()` 한 곳뿐이라 og:image 와 항상 같은 사진이다.
★ 블롭 경로가 `s/<slug>/` **밖**인 이유: `azure_static._remove_stale_site_files()` 가 매 발행마다
사이트 경로를 프리렌더 산출물로 통째로 교체한다 — 그 안에 두면 다음 발행에서 조용히 사라진다.
게이트가 **두 번** 도는 게 핵심이다. 1차는 DB 의 사실을, 2차는 **정말로 그렇게 구워졌는지**를 본다. 게이트가 **두 번** 도는 게 핵심이다. 1차는 DB 의 사실을, 2차는 **정말로 그렇게 구워졌는지**를 본다.
1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다. 1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다.

View File

@ -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 상한) `<prefix>/thumbs/<slug>.<ext>` 로 올린다. 기존
`AZURE_STORAGE_CONNECTION_STRING` 을 그대로 쓴다 — 새 자격증명 체계를 들이지 않았다.
★ 사이트 경로(`s/<slug>/`) 안에 두지 않는다: `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 — 직접 쓴 소개문이 발행에서 사라지던 구멍 ## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
**왜** **왜**

View File

@ -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 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) current_version_id uuid NULL, -- 현재 발행 버전(site.site_versions.site_version_id)
published_at TIMESTAMPTZ NULL, -- 최초/최근 발행 시각 published_at TIMESTAMPTZ NULL, -- 최초/최근 발행 시각
thumbnail_url VARCHAR(500) NULL, -- 발행 썸네일(Azure Blob 공개 URL). 발행에 성공한 뒤에만 채워진다 — 쇼케이스·사이트 목록 카드가 읽는다
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE deleted BOOLEAN NOT NULL DEFAULT FALSE

View File

@ -405,6 +405,9 @@ class sites(MainTableMixin, MAIN_BASE):
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value) 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.site_versions.site_version_id
published_at = Column(DateTime(timezone=True), nullable=True) 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): class site_versions(MainTableMixin, MAIN_BASE):

View File

@ -226,6 +226,28 @@ def fake_renderer(monkeypatch, tmp_path_factory):
if payload["site"]["version"] != site_version: if payload["site"]["version"] != site_version:
return None return None
place = payload.get("place") or {} 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 { return {
"schemaVersion": 1, "schemaVersion": 1,
"slug": slug, "slug": slug,
@ -236,7 +258,7 @@ def fake_renderer(monkeypatch, tmp_path_factory):
"renderedAt": "2026-01-01T00:00:00.000Z", "renderedAt": "2026-01-01T00:00:00.000Z",
"routes": 3, "routes": 3,
"bundle": "test.js", "bundle": "test.js",
"uniqueContentCount": _count(payload), "uniqueContentCount": count,
"jsonld": [{"@type": "LodgingBusiness", "name": place.get("name"), "jsonld": [{"@type": "LodgingBusiness", "name": place.get("name"),
"telephone": place.get("phone")}], "telephone": place.get("phone")}],
"mismatches": [], "mismatches": [],

View File

@ -5,8 +5,8 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import publish_logs, site_versions, sites from common.database.model.models import places, publish_logs, site_versions, sites
from common.enums import BuildStatus, ErrorType from common.enums import BuildStatus, ErrorType, SiteStatus
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime 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]: async def list_logs(self, cdb: AsyncSession, site_id, limit: int) -> Tuple[ErrorType, list]:
pass pass
@abstractmethod
async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]:
pass
class SiteCRUD(ISiteCRUD): class SiteCRUD(ISiteCRUD):
async def get_site_by_place(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, sites]: 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: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [] 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, []

View File

@ -17,6 +17,7 @@ import router.v1.faq.faq
import router.v1.media.media import router.v1.media.media
import router.v1.job.job import router.v1.job.job
import router.v1.site.site import router.v1.site.site
import router.v1.site.showcase
import router.v1.local.local import router.v1.local.local
API_SERVER_START_TIME = GTime.UTCStr() 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.media.media.router)
app.include_router(router.v1.job.job.router) app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.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.router)
app.include_router(router.v1.local.local.weather_router) app.include_router(router.v1.local.local.weather_router)

View File

@ -4,7 +4,15 @@ from typing import Any, Optional
from pydantic import ConfigDict 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 from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -54,6 +62,8 @@ class SiteData(WebPacketProtocol):
theme: Optional[dict[str, Any]] = None theme: Optional[dict[str, Any]] = None
current_version_id: Optional[uuid.UUID] = None current_version_id: Optional[uuid.UUID] = None
published_at: Optional[datetime] = None published_at: Optional[datetime] = None
# 발행 썸네일(Azure Blob 공개 URL). 대표 사진을 옮긴 것이고, 만들지 못했으면 없다.
thumbnail_url: Optional[str] = None
class PublishLogData(WebPacketProtocol): class PublishLogData(WebPacketProtocol):
@ -192,3 +202,24 @@ class Req_SiteStatus(SiteProtocol):
색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다.""" 색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다."""
action: PublishAction = PublishAction.SUSPEND action: PublishAction = PublishAction.SUSPEND
class ShowcaseItem(WebPacketProtocol):
"""랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다."""
name: str
category: PlaceCategory
# "강원특별자치도 양양군" 수준. 주소를 못 읽으면 없다.
region: Optional[str] = None
# 발행 주소. 랜딩과 발행본이 한 오리진이라 루트 상대경로로 준다(`/s/<slug>`).
url: str
# 없으면 화면이 글자 카드로 떨어진다(썸네일은 발행의 부수 효과라 실패할 수 있다).
thumbnail_url: Optional[str] = None
class Res_Showcase(Res_WebPacketProtocol):
items: list[ShowcaseItem] = []

View File

@ -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))

View File

@ -29,7 +29,7 @@ from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD 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.site_payload import emit_payload
from services.snapshot import build_snapshot 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) return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다. # DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.
thumbnail_url = None
if want_publish: if want_publish:
try: try:
azure_result = await azure_static.publish(slug) 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) return await _fail(str(ex), None, stamp)
if azure_result: if azure_result:
result["azure"] = 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) indexnow_result = await indexnow.submit(slug)
@ -269,13 +275,17 @@ async def run_build(job: dict) -> dict:
result["routes"] = report.get("routes") result["routes"] = report.get("routes")
if want_publish: 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( await DB_SESSION_MNG.execute_lambda_claim(
sites.DBType(), sites.DBType(),
lambda s: _site_crud.update_site( lambda s: _site_crud.update_site(s, site.site_id, site_update),
s, site.site_id,
{"status": SiteStatus.PUBLISHED.value, "current_version_id": version.site_version_id,
"published_at": now},
),
) )
# ★ 사업장 상태도 같이 올린다. 여기서 안 올리면 places.status 는 영원히 REVIEW 라, # ★ 사업장 상태도 같이 올린다. 여기서 안 올리면 places.status 는 영원히 REVIEW 라,
# 발행을 마친 가게가 사업장 목록에서 '발행 전'으로 남는다 — 사장님은 목록만 보고 # 발행을 마친 가게가 사업장 목록에서 '발행 전'으로 남는다 — 사장님은 목록만 보고
@ -291,6 +301,8 @@ async def run_build(job: dict) -> dict:
site.status = SiteStatus.PUBLISHED.value site.status = SiteStatus.PUBLISHED.value
site.current_version_id = version.site_version_id site.current_version_id = version.site_version_id
site.published_at = now site.published_at = now
if thumbnail_url:
site.thumbnail_url = thumbnail_url
result["published"] = True result["published"] = True
LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 " LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 "
f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)") f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)")

View File

@ -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

View File

@ -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"] 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 조립 ────────────────────────────────────────────────────────── # ── payload 조립 ──────────────────────────────────────────────────────────
def to_site_payload(place, snapshot: dict, site, version, links) -> dict: def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
"""스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict). """스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict).
@ -577,12 +604,10 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
# ★ sourceType/originUrl 을 반드시 싣는다 — 크롤링 이미지 재게시 권리가 미결이라(DECISIONS 1-2) # ★ sourceType/originUrl 을 반드시 싣는다 — 크롤링 이미지 재게시 권리가 미결이라(DECISIONS 1-2)
# 결론이 나면 출처로 걸러낼 수 있어야 한다. 출처를 버리면 그때 다시 수집해야 한다. # 결론이 나면 출처로 걸러낼 수 있어야 한다. 출처를 버리면 그때 다시 수집해야 한다.
media = [] media = []
primary_done = False primary_row = primary_media(snapshot)
for index, row in enumerate(snapshot.get("media") or []): for index, row in enumerate(snapshot.get("media") or []):
unit_id = row.get("unit_id") unit_id = row.get("unit_id")
# 대표 이미지(og:image)는 객실 전용 사진이 아닌 첫 장으로 한다. is_primary = row is primary_row
is_primary = not unit_id and not primary_done
primary_done = primary_done or is_primary
media.append({ media.append({
"mediaId": str(row.get("media_id") or f"m-{index}"), "mediaId": str(row.get("media_id") or f"m-{index}"),
"url": row.get("url"), "url": row.get("url"),

View File

@ -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/<slug>/`) **밖**이다.
azure_static._remove_stale_site_files 가 매 발행마다 `s/<slug>/` 를 프리렌더 산출물로
통째로 교체하므로, 그 안에 두면 다음 발행에서 조용히 사라진다.
★ 실패해도 발행을 되돌리지 않는다(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)

View File

@ -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

View File

@ -0,0 +1,172 @@
"""발행 썸네일 — 사이트의 대표 사진을 Azure Blob 으로 옮긴다.
★ 스크린샷이 아니다(헤드리스 브라우저는 영구 금지, docs/DECISIONS.md 1-1).
그래서 여기서 볼 것은 "무엇을 대표로 고르는가"와 "무엇을 받지 않는가" 두 가지다.
이 경로가 절대 하면 안 되는 것:
- 썸네일 실패로 발행을 되돌리는 것 — 정적 파일은 이미 올라갔다.
- 이미지가 아닌 응답을 그대로 올리는 것 — 카드가 깨진 그림이 된다.
- 사이트 디렉터리(`s/<slug>/`) 안에 두는 것 — 다음 발행이 통째로 지운다.
"""
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"<html>"))
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):
"""검증: 대표 사진을 받아 업로드한다.
기대결과: `<prefix>/thumbs/<slug>.<ext>` — `s/<slug>/` 안이 아니다.
★ 사이트 경로는 매 발행마다 프리렌더 산출물로 통째로 교체된다
(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

View File

@ -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"]] == ["우리지역축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"]
async def test_place_without_region_code_gets_no_local_content(db_engine, company_id): async def test_region_code_is_derived_from_the_address_when_missing(db_engine, company_id):
"""검증: region_code 가 비어 있는 사업장(수집이 지역을 특정하지 못한 경우). """검증: 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 from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, company_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제") 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": []} assert snap["local"] == {"region_code": None, "contents": []}