[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:
parent
5ef3e5a7de
commit
e8cda02a4b
@ -45,9 +45,16 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
|
||||
├ render_report.wait_for() → .status/<slug>.json 폴링
|
||||
├ 2차 게이트 (**실제 구워진 HTML** 기준: JSON-LD 불일치 · 고유 콘텐츠 수)
|
||||
├ 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 통보 (구글 미지원)
|
||||
```
|
||||
|
||||
썸네일은 **스크린샷이 아니라 그 사이트의 대표 사진(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차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다.
|
||||
|
||||
|
||||
@ -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 — 직접 쓴 소개문이 발행에서 사라지던 구멍
|
||||
|
||||
**왜**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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": [],
|
||||
|
||||
@ -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, []
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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/<slug>`).
|
||||
url: str
|
||||
# 없으면 화면이 글자 카드로 떨어진다(썸네일은 발행의 부수 효과라 실패할 수 있다).
|
||||
thumbnail_url: Optional[str] = None
|
||||
|
||||
|
||||
class Res_Showcase(Res_WebPacketProtocol):
|
||||
items: list[ShowcaseItem] = []
|
||||
|
||||
24
solution/backend/router/v1/site/showcase.py
Normal file
24
solution/backend/router/v1/site/showcase.py
Normal 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))
|
||||
@ -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')} 페이지)")
|
||||
|
||||
44
solution/backend/services/showcase_service.py
Normal file
44
solution/backend/services/showcase_service.py
Normal 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
|
||||
@ -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"),
|
||||
|
||||
156
solution/backend/services/site_thumbnail.py
Normal file
156
solution/backend/services/site_thumbnail.py
Normal 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)
|
||||
91
solution/backend/tests/test_showcase_api.py
Normal file
91
solution/backend/tests/test_showcase_api.py
Normal 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
|
||||
172
solution/backend/tests/test_site_thumbnail.py
Normal file
172
solution/backend/tests/test_site_thumbnail.py
Normal 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
|
||||
@ -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": []}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user